Big things, maybe all's better now

This commit is contained in:
AlacrisDevs
2026-02-09 11:36:39 +02:00
parent 38a0c2274d
commit a4baa1ad25
46 changed files with 4906 additions and 427 deletions

View File

@@ -175,11 +175,11 @@
if (!file) return;
if (!file.type.startsWith("image/")) {
toasts.error("Please select an image file.");
toasts.error(m.toast_error_select_image());
return;
}
if (file.size > 2 * 1024 * 1024) {
toasts.error("Image must be under 2MB.");
toasts.error(m.toast_error_image_too_large());
return;
}
@@ -193,7 +193,7 @@
.upload(path, file, { upsert: true });
if (uploadError) {
toasts.error("Failed to upload avatar.");
toasts.error(m.toast_error_upload_avatar());
return;
}
@@ -209,15 +209,15 @@
.eq("id", org.id);
if (dbError) {
toasts.error("Failed to save avatar URL.");
toasts.error(m.toast_error_save_avatar_url());
return;
}
avatarUrl = publicUrl;
await invalidateAll();
toasts.success("Avatar updated.");
toasts.success(m.toast_success_avatar_updated());
} catch (err) {
toasts.error("Avatar upload failed.");
toasts.error(m.toast_error_avatar_upload());
} finally {
isUploading = false;
input.value = "";
@@ -232,11 +232,11 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to remove avatar.");
toasts.error(m.toast_error_remove_avatar());
} else {
avatarUrl = null;
await invalidateAll();
toasts.success("Avatar removed.");
toasts.success(m.toast_success_avatar_removed());
}
isSaving = false;
}
@@ -254,12 +254,12 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to save settings.");
toasts.error(m.toast_error_save_settings());
} else if (orgSlug !== org.slug) {
window.location.href = `/${orgSlug}/settings`;
} else {
await invalidateAll();
toasts.success("Settings saved.");
toasts.success(m.toast_success_settings_saved());
}
isSaving = false;
}
@@ -278,10 +278,10 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to save preferences.");
toasts.error(m.toast_error_save_preferences());
} else {
await invalidateAll();
toasts.success("Preferences saved.");
toasts.success(m.toast_success_preferences_saved());
}
isSavingPrefs = false;
}
@@ -299,10 +299,10 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to save event defaults.");
toasts.error(m.toast_error_save_event_defaults());
} else {
await invalidateAll();
toasts.success("Event defaults saved.");
toasts.success(m.toast_success_event_defaults_saved());
}
isSavingDefaults = false;
}
@@ -320,10 +320,10 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to save feature settings.");
toasts.error(m.toast_error_save_features());
} else {
await invalidateAll();
toasts.success("Feature settings saved.");
toasts.success(m.toast_success_features_saved());
}
isSavingFeatures = false;
}
@@ -348,10 +348,10 @@
.eq("id", org.id);
if (error) {
toasts.error("Failed to save social links.");
toasts.error(m.toast_error_save_social());
} else {
await invalidateAll();
toasts.success("Social links saved.");
toasts.success(m.toast_success_social_saved());
}
isSavingSocial = false;
}

View File

@@ -96,7 +96,7 @@
}
async function disconnectOrgCalendar() {
if (!confirm("Disconnect Google Calendar?")) return;
if (!confirm(m.settings_confirm_disconnect_cal())) return;
const { error } = await supabase
.from("org_google_calendars")
.delete()
@@ -113,51 +113,107 @@
<!-- Google Calendar -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-white rounded-xl flex items-center justify-center shrink-0">
<div
class="w-10 h-10 bg-white rounded-xl flex items-center justify-center shrink-0"
>
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-heading text-body-sm text-white">Google Calendar</h3>
<h3 class="font-heading text-body-sm text-white">
Google Calendar
</h3>
<p class="text-[11px] text-light/40 mt-0.5">
Sync events between your organization and Google Calendar.
</p>
{#if orgCalendar}
<div class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div
class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div class="min-w-0 flex-1">
<p class="text-[11px] font-medium text-green-400">Connected</p>
<p class="text-body-sm text-white">{orgCalendar.calendar_name || "Google Calendar"}</p>
<p class="text-[10px] text-light/40 truncate" title={orgCalendar.calendar_id}>{orgCalendar.calendar_id}</p>
<p class="text-[10px] text-light/30 mt-1">Events sync both ways.</p>
<p
class="text-[11px] font-medium text-green-400"
>
Connected
</p>
<p class="text-body-sm text-white">
{orgCalendar.calendar_name ||
"Google Calendar"}
</p>
<p
class="text-[10px] text-light/40 truncate"
title={orgCalendar.calendar_id}
>
{orgCalendar.calendar_id}
</p>
<p class="text-[10px] text-light/30 mt-1">
Events sync both ways.
</p>
<a
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(orgCalendar.calendar_id)}"
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(
orgCalendar.calendar_id,
)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 mt-1.5"
>
<span class="material-symbols-rounded" style="font-size: 14px;">open_in_new</span>
<span
class="material-symbols-rounded"
style="font-size: 14px;"
>open_in_new</span
>
Open in Google Calendar
</a>
</div>
<Button variant="danger" size="sm" onclick={disconnectOrgCalendar}>Disconnect</Button>
<Button
variant="danger"
size="sm"
onclick={disconnectOrgCalendar}
>Disconnect</Button
>
</div>
</div>
{:else if !serviceAccountEmail}
<div class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p class="text-[11px] text-yellow-400 font-medium">Setup required</p>
<div
class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<p class="text-[11px] text-yellow-400 font-medium">
Setup required
</p>
<p class="text-[10px] text-light/40 mt-1">
A server administrator needs to configure the <code class="bg-light/10 px-1 rounded">GOOGLE_SERVICE_ACCOUNT_KEY</code> environment variable.
A server administrator needs to configure the <code
class="bg-light/10 px-1 rounded"
>GOOGLE_SERVICE_ACCOUNT_KEY</code
> environment variable.
</p>
</div>
{:else}
<div class="mt-3">
<Button size="sm" onclick={() => (showConnectModal = true)}>Connect Google Calendar</Button>
<Button
size="sm"
onclick={() => (showConnectModal = true)}
>Connect Google Calendar</Button
>
</div>
{/if}
</div>
@@ -167,12 +223,19 @@
<!-- Discord (coming soon) -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center shrink-0">
<span class="material-symbols-rounded text-white" style="font-size: 22px;">forum</span>
<div
class="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center shrink-0"
>
<span
class="material-symbols-rounded text-white"
style="font-size: 22px;">forum</span
>
</div>
<div class="flex-1">
<h3 class="font-heading text-body-sm text-white">Discord</h3>
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Discord server.</p>
<p class="text-[11px] text-light/40 mt-0.5">
Get notifications in your Discord server.
</p>
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</div>
</div>
@@ -181,12 +244,19 @@
<!-- Slack (coming soon) -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-[#4A154B] rounded-xl flex items-center justify-center shrink-0">
<span class="material-symbols-rounded text-white" style="font-size: 22px;">tag</span>
<div
class="w-10 h-10 bg-[#4A154B] rounded-xl flex items-center justify-center shrink-0"
>
<span
class="material-symbols-rounded text-white"
style="font-size: 22px;">tag</span
>
</div>
<div class="flex-1">
<h3 class="font-heading text-body-sm text-white">Slack</h3>
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Slack workspace.</p>
<p class="text-[11px] text-light/40 mt-0.5">
Get notifications in your Slack workspace.
</p>
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</div>
</div>

View File

@@ -47,6 +47,7 @@
interface Props {
supabase: SupabaseClient<Database>;
orgId: string;
orgName: string;
userId: string;
members: Member[];
roles: OrgRole[];
@@ -56,6 +57,7 @@
let {
supabase,
orgId,
orgName,
userId,
members = $bindable(),
roles,
@@ -102,6 +104,25 @@
invites = [...invites, invite as Invite];
inviteEmail = "";
showInviteModal = false;
// Send invite email (fire-and-forget)
const inviteUrl = `${window.location.origin}/invite/${(invite as Invite).token}`;
fetch("/api/send-invite-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
orgName,
inviteUrl,
role: inviteRole,
}),
}).then((res) => {
if (res.ok) {
toasts.success(m.invite_email_sent({ email }));
} else if (res.status !== 501) {
toasts.error(m.toast_error_send_invite_email());
}
});
} else if (error) {
toasts.error(m.toast_error_invite({ error: error.message }));
}
@@ -298,34 +319,34 @@
<Modal
isOpen={showInviteModal}
onClose={() => (showInviteModal = false)}
title="Invite Member"
title={m.settings_invite_title()}
>
<div class="flex flex-col gap-4">
<Input
variant="compact"
type="email"
label="Email address"
label={m.settings_invite_email()}
bind:value={inviteEmail}
placeholder="colleague@example.com"
placeholder={m.settings_invite_email_placeholder()}
/>
<Select
variant="compact"
label="Role"
label={m.settings_invite_role()}
bind:value={inviteRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer - Can view content" },
{ value: "viewer", label: m.settings_invite_role_viewer() },
{
value: "commenter",
label: "Commenter - Can view and comment",
label: m.settings_invite_role_commenter(),
},
{
value: "editor",
label: "Editor - Can create and edit content",
label: m.settings_invite_role_editor(),
},
{
value: "admin",
label: "Admin - Can manage members and settings",
label: m.settings_invite_role_admin(),
},
]}
/>
@@ -335,7 +356,8 @@
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showInviteModal = false)}>Cancel</button
onclick={() => (showInviteModal = false)}
>{m.btn_cancel()}</button
>
<button
type="button"
@@ -343,7 +365,7 @@
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={sendInvite}
>
{isSendingInvite ? "..." : "Send Invite"}
{isSendingInvite ? "..." : m.settings_invite_send()}
</button>
</div>
</div>
@@ -353,7 +375,7 @@
<Modal
isOpen={showMemberModal}
onClose={() => (showMemberModal = false)}
title="Edit Member"
title={m.settings_edit_member()}
>
{#if selectedMember}
{@const rawP = selectedMember.profiles}
@@ -369,23 +391,24 @@
/>
<div>
<p class="text-body-sm text-white">
{memberProfile?.full_name || "No name"}
{memberProfile?.full_name ||
m.settings_members_no_name()}
</p>
<p class="text-[11px] text-light/40">
{memberProfile?.email || "No email"}
{memberProfile?.email || m.settings_members_no_email()}
</p>
</div>
</div>
<Select
variant="compact"
label="Role"
label={m.settings_invite_role()}
bind:value={selectedMemberRole}
placeholder=""
options={[
{ value: "viewer", label: "Viewer" },
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
{ value: "viewer", label: m.role_viewer() },
{ value: "commenter", label: m.role_commenter() },
{ value: "editor", label: m.role_editor() },
{ value: "admin", label: m.role_admin() },
]}
/>
<button
@@ -393,7 +416,7 @@
class="text-[11px] text-error hover:underline self-start"
onclick={removeMember}
>
Remove from organization
{m.settings_members_remove()}
</button>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
@@ -401,12 +424,13 @@
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showMemberModal = false)}>Cancel</button
onclick={() => (showMemberModal = false)}
>{m.btn_cancel()}</button
>
<button
type="button"
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
onclick={updateMemberRole}>Save</button
onclick={updateMemberRole}>{m.btn_save()}</button
>
</div>
</div>

View File

@@ -81,14 +81,14 @@
];
const roleColors = [
{ value: "#ef4444", label: "Red" },
{ value: "#f59e0b", label: "Amber" },
{ value: "#10b981", label: "Emerald" },
{ value: "#3b82f6", label: "Blue" },
{ value: "#6366f1", label: "Indigo" },
{ value: "#8b5cf6", label: "Violet" },
{ value: "#ec4899", label: "Pink" },
{ value: "#6b7280", label: "Gray" },
{ value: "#ef4444", label: m.role_color_red() },
{ value: "#f59e0b", label: m.role_color_amber() },
{ value: "#10b981", label: m.role_color_emerald() },
{ value: "#3b82f6", label: m.role_color_blue() },
{ value: "#6366f1", label: m.role_color_indigo() },
{ value: "#8b5cf6", label: m.role_color_violet() },
{ value: "#ec4899", label: m.role_color_pink() },
{ value: "#6b7280", label: m.role_color_gray() },
];
function openRoleModal(role?: OrgRole) {
@@ -203,19 +203,29 @@
<div class="flex flex-col gap-2">
{#each roles as role}
<div class="bg-dark/30 border border-light/5 rounded-2xl px-4 py-3 hover:border-light/10 transition-colors">
<div
class="bg-dark/30 border border-light/5 rounded-2xl px-4 py-3 hover:border-light/10 transition-colors"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {role.color}"
></div>
<span class="text-body-sm font-medium text-white">{role.name}</span>
<span class="text-body-sm font-medium text-white"
>{role.name}</span
>
{#if role.is_system}
<span class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md">System</span>
<span
class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md"
>System</span
>
{/if}
{#if role.is_default}
<span class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md">Default</span>
<span
class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md"
>Default</span
>
{/if}
</div>
<div class="flex items-center gap-1.5">
@@ -226,7 +236,11 @@
onclick={() => openRoleModal(role)}
title="Edit"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>edit</span
>
</button>
{/if}
{#if !role.is_system}
@@ -236,20 +250,32 @@
onclick={() => deleteRole(role)}
title="Delete"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>delete</span
>
</button>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-1">
{#if role.permissions.includes("*")}
<span class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md">All Permissions</span>
<span
class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md"
>All Permissions</span
>
{:else}
{#each role.permissions.slice(0, 6) as perm}
<span class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md">{perm}</span>
<span
class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md"
>{perm}</span
>
{/each}
{#if role.permissions.length > 6}
<span class="text-[10px] text-light/30">+{role.permissions.length - 6} more</span>
<span class="text-[10px] text-light/30"
>+{role.permissions.length - 6} more</span
>
{/if}
{/if}
</div>
@@ -266,7 +292,9 @@
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="role-name" class="text-body-sm text-light/60 font-body">Name</label>
<label for="role-name" class="text-body-sm text-light/60 font-body"
>Name</label
>
<input
id="role-name"
type="text"
@@ -282,7 +310,8 @@
{#each roleColors as color}
<button
type="button"
class="w-6 h-6 rounded-full border-2 transition-all {newRoleColor === color.value
class="w-6 h-6 rounded-full border-2 transition-all {newRoleColor ===
color.value
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color.value}"
@@ -293,7 +322,8 @@
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Permissions</span>
<span class="text-body-sm text-light/60 font-body">Permissions</span
>
<div class="flex flex-col gap-2 max-h-64 overflow-y-auto">
{#each permissionGroups as group}
<div class="p-3 bg-dark/50 rounded-xl">
@@ -307,11 +337,15 @@
>
<input
type="checkbox"
checked={newRolePermissions.includes(perm)}
checked={newRolePermissions.includes(
perm,
)}
onchange={() => togglePermission(perm)}
class="rounded accent-primary"
/>
<span class="capitalize">{perm.split(".")[1]}</span>
<span class="capitalize"
>{perm.split(".")[1]}</span
>
</label>
{/each}
</div>
@@ -319,15 +353,25 @@
{/each}
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-2 border-t border-light/5">
<button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showRoleModal = false)}>{m.btn_cancel()}</button>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showRoleModal = false)}>{m.btn_cancel()}</button
>
<button
type="button"
disabled={!newRoleName.trim() || isSavingRole}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={saveRole}
>
{isSavingRole ? "..." : editingRole ? m.btn_save() : m.btn_create()}
{isSavingRole
? "..."
: editingRole
? m.btn_save()
: m.btn_create()}
</button>
</div>
</div>