UI redesign vol1
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Root",
|
||||
"nav_home": "Home",
|
||||
"nav_files": "Files",
|
||||
"nav_storage": "Storage",
|
||||
"nav_calendar": "Calendar",
|
||||
"nav_settings": "Settings",
|
||||
"nav_kanban": "Kanban",
|
||||
@@ -43,6 +45,7 @@
|
||||
"org_selector_slug_placeholder": "my-team",
|
||||
"org_overview": "Organization Overview",
|
||||
"files_title": "Files",
|
||||
"files_subtitle": "Organization files are stored here.",
|
||||
"files_breadcrumb_home": "Home",
|
||||
"files_create_title": "Create New",
|
||||
"files_type_document": "Document",
|
||||
@@ -82,6 +85,7 @@
|
||||
"kanban_go_to_files": "Go to Files",
|
||||
"kanban_select_board": "Select a board above",
|
||||
"calendar_title": "Calendar",
|
||||
"calendar_subtitle": "Keep track of organization events here.",
|
||||
"calendar_subscribe": "Subscribe to Calendar",
|
||||
"calendar_refresh": "Refresh Events",
|
||||
"calendar_settings": "Calendar Settings",
|
||||
@@ -1035,5 +1039,21 @@
|
||||
"settings_transfer_ownership": "Transfer ownership",
|
||||
"settings_transfer_confirm": "Transfer ownership to {name}? You will be demoted to admin. This action is immediate.",
|
||||
"toast_error_transfer_ownership": "Failed to transfer ownership",
|
||||
"toast_success_transfer_ownership": "Ownership transferred to {name}"
|
||||
"toast_success_transfer_ownership": "Ownership transferred to {name}",
|
||||
"home_nav_organizations": "Organizations",
|
||||
"home_title": "Your Organizations",
|
||||
"home_subtitle": "Select an Organization to get started.",
|
||||
"home_empty_title": "No organizations yet",
|
||||
"home_empty_desc": "Create your first organization to start collaborating",
|
||||
"home_pending_invitations": "Pending Invitations",
|
||||
"home_invite_click_accept": "Click to accept",
|
||||
"home_invite_joining": "Joining...",
|
||||
"home_create_org_title": "Create Organization",
|
||||
"home_create_org_name_label": "Organization Name",
|
||||
"home_create_org_name_placeholder": "e.g. Acme Inc",
|
||||
"home_create_org_url_preview": "URL: /{slug}",
|
||||
"account_settings_title": "Your Settings",
|
||||
"account_settings_subtitle": "Manage your settings here.",
|
||||
"user_settings_title": "User Settings",
|
||||
"user_settings_subtitle": "Manage your personal account settings."
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"app_name": "Root",
|
||||
"nav_home": "Avaleht",
|
||||
"nav_files": "Failid",
|
||||
"nav_storage": "Hoidla",
|
||||
"nav_calendar": "Kalender",
|
||||
"nav_settings": "Seaded",
|
||||
"nav_kanban": "Kanban",
|
||||
@@ -42,6 +44,7 @@
|
||||
"org_selector_slug_placeholder": "minu-meeskond",
|
||||
"org_overview": "Organisatsiooni ülevaade",
|
||||
"files_title": "Failid",
|
||||
"files_subtitle": "Organisatsiooni failid on siin.",
|
||||
"files_breadcrumb_home": "Avaleht",
|
||||
"files_create_title": "Loo uus",
|
||||
"files_type_document": "Dokument",
|
||||
@@ -81,6 +84,7 @@
|
||||
"kanban_go_to_files": "Mine Failidesse",
|
||||
"kanban_select_board": "Vali tahvel ülalt",
|
||||
"calendar_title": "Kalender",
|
||||
"calendar_subtitle": "Jälgi organisatsiooni sündmusi siin.",
|
||||
"calendar_subscribe": "Telli kalender",
|
||||
"calendar_refresh": "Värskenda sündmusi",
|
||||
"calendar_settings": "Kalendri seaded",
|
||||
@@ -1035,5 +1039,21 @@
|
||||
"settings_transfer_ownership": "Kanna omanikõigused üle",
|
||||
"settings_transfer_confirm": "Kanna omanikõigused üle kasutajale {name}? Sind alandatakse adminiks. See toiming on kohene.",
|
||||
"toast_error_transfer_ownership": "Omanikõiguste ülekandmine ebaõnnestus",
|
||||
"toast_success_transfer_ownership": "Omanikõigused kanti üle kasutajale {name}"
|
||||
"toast_success_transfer_ownership": "Omanikõigused kanti üle kasutajale {name}",
|
||||
"home_nav_organizations": "Organisatsioonid",
|
||||
"home_title": "Sinu organisatsioonid",
|
||||
"home_subtitle": "Vali organisatsioon alustamiseks.",
|
||||
"home_empty_title": "Organisatsioone pole veel",
|
||||
"home_empty_desc": "Loo oma esimene organisatsioon koostöö alustamiseks",
|
||||
"home_pending_invitations": "Ootel kutsed",
|
||||
"home_invite_click_accept": "Kliki nõustumiseks",
|
||||
"home_invite_joining": "Liitun...",
|
||||
"home_create_org_title": "Loo organisatsioon",
|
||||
"home_create_org_name_label": "Organisatsiooni nimi",
|
||||
"home_create_org_name_placeholder": "nt. Acme OÜ",
|
||||
"home_create_org_url_preview": "URL: /{slug}",
|
||||
"account_settings_title": "Sinu seaded",
|
||||
"account_settings_subtitle": "Halda oma seadeid siin.",
|
||||
"user_settings_title": "Kasutaja seaded",
|
||||
"user_settings_subtitle": "Halda oma isiklikke konto seadeid."
|
||||
}
|
||||
@@ -141,10 +141,7 @@
|
||||
}
|
||||
|
||||
function getDocColor(doc: Document): string {
|
||||
if (doc.type === "folder") return "text-amber-400";
|
||||
if (doc.type === "kanban") return "text-purple-400";
|
||||
if (doc.type === "file") return "text-pink-400";
|
||||
return "text-light/50";
|
||||
return "text-white";
|
||||
}
|
||||
|
||||
function handleItemClick(doc: Document) {
|
||||
@@ -638,29 +635,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar: Breadcrumbs + Actions -->
|
||||
<!-- Breadcrumb Bar -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0"
|
||||
class="flex flex-wrap items-center justify-between gap-y-2 w-full shrink-0"
|
||||
>
|
||||
<!-- Breadcrumb Path -->
|
||||
<nav class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
|
||||
<nav class="flex items-center flex-1 min-w-0 overflow-x-auto">
|
||||
{#each breadcrumbPath as crumb, i}
|
||||
{#if i > 0}
|
||||
<span
|
||||
class="material-symbols-rounded text-light/20 shrink-0"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
chevron_right
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_forward</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
<a
|
||||
href={getFolderUrl(crumb.id)}
|
||||
class="px-2 py-1 rounded-lg text-body-sm font-body whitespace-nowrap transition-colors
|
||||
{crumb.id === currentFolderId
|
||||
? 'text-white bg-dark/30'
|
||||
: 'text-light/50 hover:text-white hover:bg-dark/30'}
|
||||
class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity
|
||||
{dragOverBreadcrumb === (crumb.id ?? '__root__')
|
||||
? 'ring-2 ring-primary bg-primary/10'
|
||||
? 'ring-2 ring-primary bg-primary/10 rounded-lg'
|
||||
: ''}"
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -689,53 +684,49 @@
|
||||
resetDragState();
|
||||
}}
|
||||
>
|
||||
{#if i === 0}
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>home</span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{i === 0 ? "cloud" : "folder"}</span
|
||||
>
|
||||
{:else}
|
||||
{crumb.name}
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="font-heading text-h3 text-white whitespace-nowrap"
|
||||
>{crumb.name}</span
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
{#if fileUploading}
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-primary/5 rounded-lg"
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if fileUploading}
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-primary/10 rounded-[32px]"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-primary animate-spin"
|
||||
style="font-size: 14px;">progress_activity</span
|
||||
>
|
||||
<span
|
||||
class="text-body-sm text-primary truncate max-w-[200px]"
|
||||
>{fileUploadProgress}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 shrink-0 hover:bg-white/5 rounded-lg transition-colors"
|
||||
title={m.files_toggle_view()}
|
||||
onclick={toggleViewMode}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-primary animate-spin"
|
||||
style="font-size: 14px;">progress_activity</span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{viewMode === "list" ? "grid_view" : "list"}</span
|
||||
>
|
||||
<span
|
||||
class="text-[11px] text-primary truncate max-w-[200px]"
|
||||
>{fileUploadProgress}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
icon="upload"
|
||||
onclick={() => fileUploadInput?.click()}>Upload</Button
|
||||
>
|
||||
<Button size="sm" icon="add" onclick={handleAdd}
|
||||
>{m.btn_new()}</Button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 rounded-lg text-light/40 hover:text-white hover:bg-dark/50 transition-colors"
|
||||
title={m.files_toggle_view()}
|
||||
onclick={toggleViewMode}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||
>{viewMode === "list" ? "grid_view" : "view_list"}</span
|
||||
>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List/Grid -->
|
||||
@@ -819,28 +810,32 @@
|
||||
{:else}
|
||||
<!-- Grid View -->
|
||||
<div
|
||||
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2"
|
||||
class="flex flex-wrap gap-8 items-start content-start"
|
||||
ondragover={handleContainerDragOver}
|
||||
ondrop={handleDropOnEmpty}
|
||||
role="list"
|
||||
>
|
||||
{#if currentFolderItems.length === 0}
|
||||
<div
|
||||
class="col-span-full flex flex-col items-center justify-center text-light/40 py-16"
|
||||
class="w-full flex flex-col items-center justify-center text-white/30 py-16"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded mb-3"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>folder_open</span
|
||||
>
|
||||
<p class="text-body-sm">{m.files_empty()}</p>
|
||||
<p class="font-body text-body-sm">
|
||||
{m.files_empty()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each currentFolderItems as item}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-2 p-3 rounded-xl border border-transparent transition-all hover:bg-dark/50 hover:border-light/5
|
||||
{selectedDoc?.id === item.id ? 'bg-dark/50 border-primary/20' : ''}
|
||||
class="flex flex-col gap-2 items-center justify-center w-[240px] min-w-[240px] h-[155px] min-h-[155px] p-4 rounded-[32px] overflow-clip transition-all cursor-pointer
|
||||
{selectedDoc?.id === item.id
|
||||
? 'bg-surface ring-2 ring-primary/50'
|
||||
: 'hover:bg-surface/50'}
|
||||
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
||||
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
||||
draggable="true"
|
||||
@@ -856,27 +851,20 @@
|
||||
handleContextMenu(e, item)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded {getDocColor(
|
||||
item,
|
||||
)}"
|
||||
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
|
||||
class="flex items-center justify-center p-3"
|
||||
>
|
||||
{getDocIcon(item)}
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 72px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>
|
||||
{getDocIcon(item)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="font-body text-[12px] text-white text-center truncate w-full"
|
||||
>{item.name}</span
|
||||
<p
|
||||
class="font-body text-body text-white text-center truncate w-full min-w-full"
|
||||
>
|
||||
{#if item.type === "file"}
|
||||
{@const fileMeta = getFileMetadata(item)}
|
||||
{#if fileMeta}
|
||||
<span class="text-[10px] text-light/25"
|
||||
>{formatFileSize(
|
||||
fileMeta.file_size,
|
||||
)}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
{item.name}
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -961,7 +949,7 @@
|
||||
: m.files_doc_placeholder()}
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button variant="tertiary" onclick={() => (showCreateModal = false)}
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
<Button onclick={handleCreate} disabled={!newDocName.trim()}
|
||||
@@ -1092,7 +1080,7 @@
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
showEditModal = false;
|
||||
editingDoc = null;
|
||||
|
||||
@@ -924,7 +924,7 @@
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="tertiary" onclick={onClose}
|
||||
<Button variant="ghost" onclick={onClose}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-2 overflow-x-auto pb-4 h-full kanban-scroll"
|
||||
class="flex gap-4 overflow-x-auto pb-4 h-full kanban-scroll"
|
||||
role="presentation"
|
||||
>
|
||||
{#each columns as column, colIndex (column.id)}
|
||||
@@ -301,7 +301,7 @@
|
||||
<!-- Add Card Button -->
|
||||
{#if canEdit}
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
icon="add"
|
||||
onclick={() => onAddCard?.(column.id)}
|
||||
@@ -315,7 +315,7 @@
|
||||
{#if canEdit}
|
||||
<div class="flex-shrink-0 w-[256px]">
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
icon="add"
|
||||
onclick={() => onAddColumn?.()}
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-night/80 border border-light/5 hover:border-light/10 rounded-xl px-3 py-2.5 cursor-pointer transition-all group w-full text-left flex flex-col gap-1.5 relative"
|
||||
class="bg-surface rounded-[16px] p-4 cursor-pointer transition-all group w-full text-left flex flex-col gap-2 relative hover:ring-1 hover:ring-white/10"
|
||||
class:opacity-50={isDragging}
|
||||
data-card-id={card.id}
|
||||
{draggable}
|
||||
@@ -71,13 +71,13 @@
|
||||
<!-- svelte-ignore node_invalid_placement_ssr -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1.5 right-1.5 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10"
|
||||
class="absolute top-2 right-2 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10"
|
||||
onclick={handleDelete}
|
||||
aria-label="Delete card"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
class="material-symbols-rounded text-white/30 hover:text-error"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
close
|
||||
</span>
|
||||
@@ -86,10 +86,10 @@
|
||||
|
||||
<!-- Tags / Chips -->
|
||||
{#if card.tags && card.tags.length > 0}
|
||||
<div class="flex gap-1 items-start flex-wrap">
|
||||
<div class="flex gap-1.5 items-start flex-wrap">
|
||||
{#each card.tags as tag}
|
||||
<span
|
||||
class="rounded-[4px] px-1.5 py-0.5 font-body font-bold text-[11px] text-night leading-none"
|
||||
class="rounded-[32px] px-2.5 py-1 font-body font-bold text-body-sm text-night leading-none"
|
||||
style="background-color: {tag.color || '#00A3E0'}"
|
||||
>
|
||||
{tag.name}
|
||||
@@ -99,19 +99,19 @@
|
||||
{/if}
|
||||
|
||||
<!-- Title -->
|
||||
<p class="font-body text-body-sm text-white w-full leading-snug">
|
||||
<p class="font-body text-body text-white w-full leading-snug">
|
||||
{card.title}
|
||||
</p>
|
||||
|
||||
<!-- Bottom row: details + avatar -->
|
||||
{#if hasFooter}
|
||||
<div class="flex items-center justify-between w-full mt-0.5">
|
||||
<div class="flex gap-2 items-center text-[11px] text-light/40">
|
||||
<div class="flex items-center justify-between w-full mt-1">
|
||||
<div class="flex gap-3 items-center text-body-sm text-white/40">
|
||||
{#if card.due_date}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>calendar_today</span
|
||||
>
|
||||
{formatDueDate(card.due_date)}
|
||||
@@ -119,10 +119,10 @@
|
||||
{/if}
|
||||
|
||||
{#if (card.checklist_total ?? 0) > 0}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>check_box</span
|
||||
>
|
||||
{card.checklist_done ?? 0}/{card.checklist_total}
|
||||
@@ -134,7 +134,7 @@
|
||||
<Avatar
|
||||
name={card.assignee_name || "?"}
|
||||
src={card.assignee_avatar}
|
||||
size="xs"
|
||||
size="sm"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
<Spinner />
|
||||
{:else if hasMore}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="expand_more"
|
||||
onclick={() => loadEntries(false)}
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
</Button>
|
||||
{#if avatarUrl}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={removeAvatar}
|
||||
>
|
||||
|
||||
@@ -298,7 +298,7 @@
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={copyServiceEmail}
|
||||
>
|
||||
{emailCopied ? "Copied!" : "Copy"}
|
||||
@@ -330,9 +330,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showConnectModal = false)}>Cancel</Button
|
||||
<Button variant="ghost" onclick={() => (showConnectModal = false)}
|
||||
>Cancel</Button
|
||||
>
|
||||
<Button
|
||||
onclick={handleSaveOrgCalendar}
|
||||
|
||||
63
src/lib/components/ui/Breadcrumb.svelte
Normal file
63
src/lib/components/ui/Breadcrumb.svelte
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
onToggleView?: () => void;
|
||||
viewMode?: 'grid' | 'list';
|
||||
}
|
||||
|
||||
let { items, onToggleView, viewMode = 'grid' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 w-full">
|
||||
<div class="flex items-center">
|
||||
{#each items as item, i}
|
||||
{#if i > 0}
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_forward</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.href}
|
||||
<a href={item.href} class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity">
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{item.icon}</span>
|
||||
</span>
|
||||
<span class="font-heading text-h3 text-white whitespace-nowrap">{item.label}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{item.icon}</span>
|
||||
</span>
|
||||
<span class="font-heading text-h3 text-white whitespace-nowrap">{item.label}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if onToggleView}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 shrink-0 hover:bg-white/5 rounded-lg transition-colors"
|
||||
onclick={onToggleView}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{viewMode === 'grid' ? 'list' : 'grid_view'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2,7 +2,13 @@
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
variant?: "primary" | "secondary" | "tertiary" | "danger" | "success";
|
||||
variant?:
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "outline"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -28,36 +34,26 @@
|
||||
}: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
"inline-flex items-center justify-center gap-2 font-heading rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
"inline-flex items-center justify-center gap-2 font-bold font-body rounded-[32px] overflow-clip transition-all cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"btn-primary bg-primary text-night hover:btn-primary-hover active:btn-primary-active",
|
||||
secondary:
|
||||
"bg-transparent text-primary border-solid border-primary hover:bg-primary/10 active:bg-primary/20",
|
||||
tertiary:
|
||||
"bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
|
||||
danger: "btn-primary bg-error text-white hover:btn-primary-hover active:btn-primary-active",
|
||||
"bg-primary text-night hover:brightness-110 active:brightness-90",
|
||||
secondary: "bg-night text-white hover:bg-surface active:bg-dark",
|
||||
outline:
|
||||
"bg-transparent text-primary border-2 border-primary hover:bg-primary/10 active:bg-primary/20",
|
||||
danger: "bg-error text-white hover:brightness-110 active:brightness-90",
|
||||
success:
|
||||
"btn-primary bg-success text-night hover:btn-primary-hover active:btn-primary-active",
|
||||
"bg-success text-night hover:brightness-110 active:brightness-90",
|
||||
ghost: "bg-transparent text-white hover:bg-white/5 active:bg-white/10",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "min-w-[36px] p-[10px] text-btn-sm",
|
||||
md: "min-w-[48px] p-[12px] text-btn-md",
|
||||
lg: "min-w-[56px] p-[16px] text-btn-lg",
|
||||
sm: "min-w-[36px] px-[12px] py-[8px] text-btn-sm",
|
||||
md: "min-w-[48px] px-[14px] py-[10px] text-btn-md",
|
||||
lg: "min-w-[56px] px-[16px] py-[12px] text-btn-lg",
|
||||
};
|
||||
|
||||
const borderClasses = {
|
||||
sm: "border-2",
|
||||
md: "border-3",
|
||||
lg: "border-4",
|
||||
};
|
||||
|
||||
const secondaryBorder = $derived(
|
||||
variant === "secondary" ? borderClasses[size] : "",
|
||||
);
|
||||
|
||||
const iconSize = $derived(size === "sm" ? 16 : size === "lg" ? 20 : 18);
|
||||
</script>
|
||||
|
||||
@@ -65,7 +61,7 @@
|
||||
{type}
|
||||
class="{baseClasses} {variantClasses[variant]} {sizeClasses[
|
||||
size
|
||||
]} {secondaryBorder} {className ?? ''}"
|
||||
]} {className ?? ''}"
|
||||
class:w-full={fullWidth}
|
||||
disabled={disabled || loading}
|
||||
{onclick}
|
||||
@@ -89,30 +85,3 @@
|
||||
{@render children()}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-image: linear-gradient(
|
||||
rgba(255, 255, 255, 0.2),
|
||||
rgba(255, 255, 255, 0.2)
|
||||
);
|
||||
}
|
||||
.btn-primary-hover:not(:disabled) {
|
||||
background-image: linear-gradient(
|
||||
rgba(255, 255, 255, 0.2),
|
||||
rgba(255, 255, 255, 0.2)
|
||||
);
|
||||
}
|
||||
.btn-primary:active:not(:disabled) {
|
||||
background-image: linear-gradient(
|
||||
rgba(14, 15, 25, 0.2),
|
||||
rgba(14, 15, 25, 0.2)
|
||||
);
|
||||
}
|
||||
.btn-primary-active:not(:disabled) {
|
||||
background-image: linear-gradient(
|
||||
rgba(14, 15, 25, 0.2),
|
||||
rgba(14, 15, 25, 0.2)
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,39 +4,54 @@
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actionLabel?: string;
|
||||
actionIcon?: string;
|
||||
onAction?: () => void;
|
||||
onMore?: () => void;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, actionLabel, onAction, onMore, children }: Props = $props();
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
actionLabel,
|
||||
actionIcon,
|
||||
onAction,
|
||||
onMore,
|
||||
children,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 p-1 rounded-[32px] w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="font-heading text-h1 text-white truncate">{title}</h1>
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white">{title}</h2>
|
||||
{#if subtitle}
|
||||
<p class="font-body text-body text-white/50">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{#if actionLabel && onAction}
|
||||
<Button variant="primary" onclick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if onMore}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors"
|
||||
onclick={onMore}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
{#if actionLabel && onAction}
|
||||
<Button variant="primary" icon={actionIcon} onclick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if onMore}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 rounded-full hover:bg-white/5 transition-colors"
|
||||
onclick={onMore}
|
||||
>
|
||||
more_horiz
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
more_horiz
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
@@ -33,6 +34,7 @@
|
||||
value = $bindable(""),
|
||||
placeholder = "",
|
||||
label,
|
||||
description,
|
||||
error,
|
||||
hint,
|
||||
disabled = false,
|
||||
@@ -55,16 +57,22 @@
|
||||
const inputType = $derived(isPassword && showPassword ? "text" : type);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col {isCompact ? 'gap-1.5' : 'gap-3'} w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
{#if label}
|
||||
<label
|
||||
for={inputId}
|
||||
class={isCompact
|
||||
? "font-body text-body-sm text-light/60"
|
||||
: "px-3 font-bold font-body text-body text-white"}
|
||||
>
|
||||
{#if required}<span class="text-error">* </span>{/if}{label}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 {isCompact ? '' : 'px-3'}">
|
||||
<label
|
||||
for={inputId}
|
||||
class={isCompact
|
||||
? "font-body text-body-sm text-light/60"
|
||||
: "font-bold font-body text-body text-white"}
|
||||
>
|
||||
{#if required}<span class="text-error">* </span
|
||||
>{/if}{label}
|
||||
</label>
|
||||
{#if description}
|
||||
<p class="text-body-sm text-white/50">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3 w-full">
|
||||
@@ -97,23 +105,20 @@
|
||||
class="
|
||||
w-full {isCompact
|
||||
? 'px-3 py-2 bg-dark border border-light/10 rounded-xl text-body-sm'
|
||||
: 'p-3 bg-background rounded-[32px] font-medium font-input text-body'}
|
||||
text-white placeholder:text-light/30
|
||||
focus:outline-none {isCompact
|
||||
? 'focus:border-primary'
|
||||
: 'focus:ring-2 focus:ring-primary'}
|
||||
: 'px-3 py-2 bg-night rounded-[20px] font-medium font-input text-body'}
|
||||
text-white placeholder:text-white/50
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/50
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors {className ?? ''}
|
||||
"
|
||||
class:ring-1={error && !isCompact}
|
||||
class:ring-error={error && !isCompact}
|
||||
class:border-error={error && isCompact}
|
||||
class:ring-1={error}
|
||||
class:ring-error={error}
|
||||
class:pr-12={isPassword}
|
||||
/>
|
||||
{#if isPassword}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 hover:text-white transition-colors"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full text-white/60 hover:text-white transition-colors"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
aria-label={showPassword
|
||||
? "Hide password"
|
||||
@@ -121,7 +126,7 @@
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-light/5">
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span class="font-heading text-body-sm text-white truncate">{title}</span>
|
||||
<span class="font-heading text-body-sm text-white truncate"
|
||||
>{title}</span
|
||||
>
|
||||
<span
|
||||
class="text-[11px] text-light/40 bg-light/5 px-1.5 py-0.5 rounded-md shrink-0"
|
||||
>{count}</span>
|
||||
>{count}</span
|
||||
>
|
||||
</div>
|
||||
{#if onMore}
|
||||
<button
|
||||
@@ -41,9 +44,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Cards container -->
|
||||
<div
|
||||
class="flex-1 flex flex-col gap-1.5 p-2 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div class="flex-1 flex flex-col gap-1.5 p-2 overflow-y-auto min-h-0">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
@@ -52,7 +53,13 @@
|
||||
<!-- Add button -->
|
||||
{#if onAddCard}
|
||||
<div class="px-2 pb-2">
|
||||
<Button variant="tertiary" fullWidth size="sm" icon="add" onclick={onAddCard}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
size="sm"
|
||||
icon="add"
|
||||
onclick={onAddCard}
|
||||
>
|
||||
Add card
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
55
src/lib/components/ui/ListCard.svelte
Normal file
55
src/lib/components/ui/ListCard.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
type: 'folder' | 'document' | 'kanban';
|
||||
href?: string;
|
||||
onclick?: () => void;
|
||||
ondblclick?: () => void;
|
||||
onauxclick?: (e: MouseEvent) => void;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
let { label, type, href, onclick, ondblclick, onauxclick, selected = false }: Props = $props();
|
||||
|
||||
const iconMap = {
|
||||
folder: 'folder',
|
||||
document: 'description',
|
||||
kanban: 'view_kanban',
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
class="flex flex-col gap-2 items-center justify-center w-[240px] min-w-[240px] h-[155px] min-h-[155px] p-4 rounded-[32px] overflow-clip transition-all cursor-pointer
|
||||
{selected ? 'bg-surface ring-2 ring-primary/50' : 'hover:bg-surface/50'}"
|
||||
{onclick}
|
||||
{ondblclick}
|
||||
{onauxclick}
|
||||
>
|
||||
<span class="flex items-center justify-center p-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 72px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>{iconMap[type]}</span>
|
||||
</span>
|
||||
<p class="font-body text-body text-white text-center truncate w-full min-w-full">{label}</p>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col gap-2 items-center justify-center w-[240px] min-w-[240px] h-[155px] min-h-[155px] p-4 rounded-[32px] overflow-clip transition-all cursor-pointer
|
||||
{selected ? 'bg-surface ring-2 ring-primary/50' : 'hover:bg-surface/50'}"
|
||||
{onclick}
|
||||
{ondblclick}
|
||||
{onauxclick}
|
||||
>
|
||||
<span class="flex items-center justify-center p-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 72px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>{iconMap[type]}</span>
|
||||
</span>
|
||||
<p class="font-body text-body text-white text-center truncate w-full min-w-full">{label}</p>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -20,9 +20,7 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="flex items-center justify-between px-6 py-5 border-b border-light/5 shrink-0 {className}"
|
||||
>
|
||||
<header class="flex items-center justify-between shrink-0 {className}">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
{#if icon}
|
||||
<span
|
||||
@@ -31,10 +29,10 @@
|
||||
>{icon}</span
|
||||
>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-h1 font-heading text-white truncate">{title}</h1>
|
||||
<div class="min-w-0 flex flex-col gap-2">
|
||||
<h2 class="text-h2 font-heading text-white truncate">{title}</h2>
|
||||
{#if subtitle}
|
||||
<p class="text-body-sm text-light/50 mt-0.5">{subtitle}</p>
|
||||
<p class="text-body text-white/50 font-body">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,13 +24,11 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-xl {paddingClasses[padding]} {className}"
|
||||
>
|
||||
<div class="bg-surface rounded-[32px] {paddingClasses[padding]} {className}">
|
||||
{#if title || titleRight}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
{#if title}
|
||||
<h2 class="text-body font-heading text-white">{title}</h2>
|
||||
<h3 class="text-h4 font-heading text-white">{title}</h3>
|
||||
{/if}
|
||||
{#if titleRight}
|
||||
{@render titleRight()}
|
||||
|
||||
@@ -66,11 +66,9 @@
|
||||
{onchange}
|
||||
class="w-full {isCompact
|
||||
? 'px-3 py-2 bg-dark border border-light/10 rounded-xl text-body-sm'
|
||||
: 'p-3 bg-background rounded-[32px] font-medium font-input text-body'}
|
||||
: 'px-3 py-2 bg-night rounded-[20px] font-medium font-input text-body'}
|
||||
text-white
|
||||
focus:outline-none {isCompact
|
||||
? 'focus:border-primary'
|
||||
: 'focus:ring-2 focus:ring-primary'}
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/50
|
||||
disabled:opacity-30 disabled:cursor-not-allowed
|
||||
transition-colors appearance-none cursor-pointer {className ?? ''}"
|
||||
class:ring-1={error && !isCompact}
|
||||
|
||||
@@ -21,38 +21,36 @@
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
class="bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-4 flex items-center gap-3 transition-all group"
|
||||
class="bg-surface rounded-[32px] p-5 flex items-center gap-4 transition-all hover:ring-1 hover:ring-white/10 group"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl {bg} flex items-center justify-center shrink-0"
|
||||
class="w-12 h-12 rounded-full {bg} flex items-center justify-center shrink-0"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded {color}"
|
||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{icon}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-white leading-none">{value}</p>
|
||||
<p class="text-[12px] text-light/40 mt-0.5">{label}</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-h2 font-heading text-white leading-none">{value}</p>
|
||||
<p class="text-body font-body text-white/50">{label}</p>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-xl p-4 flex items-center gap-3"
|
||||
>
|
||||
<div class="bg-surface rounded-[32px] p-5 flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl {bg} flex items-center justify-center shrink-0"
|
||||
class="w-12 h-12 rounded-full {bg} flex items-center justify-center shrink-0"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded {color}"
|
||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{icon}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xl font-bold text-white leading-none">{value}</p>
|
||||
<p class="text-[12px] text-light/40 mt-0.5">{label}</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-h2 font-heading text-white leading-none">{value}</p>
|
||||
<p class="text-body font-body text-white/50">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -43,3 +43,5 @@ export { default as Twemoji } from './Twemoji.svelte';
|
||||
export { default as EmojiPicker } from './EmojiPicker.svelte';
|
||||
export { default as VirtualList } from './VirtualList.svelte';
|
||||
export { default as PersonContactModal } from './PersonContactModal.svelte';
|
||||
export { default as Breadcrumb } from './Breadcrumb.svelte';
|
||||
export { default as ListCard } from './ListCard.svelte';
|
||||
|
||||
@@ -36,7 +36,9 @@ ${dump}
|
||||
<div class="space-y-2">
|
||||
<p class="text-[80px] font-heading text-primary">{$page.status}</p>
|
||||
<h1 class="text-2xl font-heading text-white">
|
||||
{$page.status === 404 ? "Page not found" : "Something went wrong"}
|
||||
{$page.status === 404
|
||||
? "Page not found"
|
||||
: "Something went wrong"}
|
||||
</h1>
|
||||
<p class="text-light/60 text-base">
|
||||
{$page.error?.message || "An unexpected error occurred."}
|
||||
@@ -54,10 +56,10 @@ ${dump}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-center flex-wrap">
|
||||
<Button onclick={() => window.location.href = "/"}>
|
||||
<Button onclick={() => (window.location.href = "/")}>
|
||||
Go Home
|
||||
</Button>
|
||||
<Button variant="tertiary" onclick={() => window.location.reload()}>
|
||||
<Button variant="ghost" onclick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleCopyLogs}>
|
||||
@@ -75,7 +77,9 @@ ${dump}
|
||||
</button>
|
||||
|
||||
{#if showLogs}
|
||||
<pre class="mt-4 p-4 bg-dark rounded-[16px] text-left text-xs text-light/70 overflow-auto max-h-[300px] font-mono whitespace-pre-wrap">{logDump || "No recent logs."}</pre>
|
||||
<pre
|
||||
class="mt-4 p-4 bg-dark rounded-[16px] text-left text-xs text-light/70 overflow-auto max-h-[300px] font-mono whitespace-pre-wrap">{logDump ||
|
||||
"No recent logs."}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,8 +41,21 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
org: (inv as Record<string, unknown>).organizations as { id: string; name: string; slug: string } | null,
|
||||
})).filter((inv) => inv.org !== null);
|
||||
|
||||
// Fetch user profile for sidebar
|
||||
const { data: profile } = await locals.supabase
|
||||
.from('profiles')
|
||||
.select('full_name, avatar_url')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
return {
|
||||
organizations,
|
||||
pendingInvites,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: profile?.full_name ?? user.user_metadata?.full_name ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Modal, Logo, Badge } from "$lib/components/ui";
|
||||
import { page } from "$app/stores";
|
||||
import { Modal, Logo, Avatar, Button, Input } from "$lib/components/ui";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import { createOrganization, generateSlug } from "$lib/api/organizations";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
@@ -27,7 +29,12 @@
|
||||
data: {
|
||||
organizations: OrgWithRole[];
|
||||
pendingInvites: PendingInvite[];
|
||||
user: any;
|
||||
user: {
|
||||
id: string;
|
||||
email: string | undefined;
|
||||
fullName: string | null;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,250 +129,292 @@
|
||||
<title>Organizations | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-light/5">
|
||||
<div
|
||||
class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Logo size="sm" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if pendingInvites.length > 0}
|
||||
<a
|
||||
href="#invites"
|
||||
class="relative p-2 text-light/40 hover:text-white hover:bg-dark/50 rounded-xl transition-colors"
|
||||
title="{pendingInvites.length} pending invite{pendingInvites.length >
|
||||
1
|
||||
? 's'
|
||||
: ''}"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||
>notifications</span
|
||||
>
|
||||
<span
|
||||
class="absolute -top-0.5 -right-0.5 w-5 h-5 bg-primary text-background text-[10px] font-bold rounded-full flex items-center justify-center"
|
||||
>
|
||||
{pendingInvites.length}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-1.5 text-body-sm text-light/40 hover:text-white hover:bg-dark/50 rounded-xl transition-colors"
|
||||
>Sign Out</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="h-screen bg-background flex gap-4 items-start p-4">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="bg-night flex flex-col gap-4 h-full items-start min-w-[256px] overflow-clip px-4 py-5 rounded-[32px] shrink-0 w-[256px]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<Logo size="sm" />
|
||||
|
||||
<!-- Nav list -->
|
||||
<nav class="flex flex-1 flex-col gap-1 items-start w-full min-h-0">
|
||||
{#each [{ href: "/", icon: "apartment", label: m.home_nav_organizations() }, { href: "/settings", icon: "settings", label: m.nav_settings() }] as item}
|
||||
{@const active =
|
||||
item.href === "/"
|
||||
? $page.url.pathname === "/"
|
||||
: $page.url.pathname.startsWith(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex gap-2 items-center w-full overflow-clip pl-1 pr-2 py-1 rounded-[32px] transition-colors {active
|
||||
? 'bg-primary'
|
||||
: 'hover:bg-white/5'}"
|
||||
>
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded {active
|
||||
? 'text-background'
|
||||
: 'text-white'}"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{item.icon}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="flex-1 font-body text-body truncate {active
|
||||
? 'text-background'
|
||||
: 'text-white'}">{item.label}</span
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Profile footer -->
|
||||
<div class="flex gap-4 items-center w-full">
|
||||
<div class="flex flex-1 gap-4 items-center min-w-0">
|
||||
<Avatar
|
||||
name={data.user.fullName || data.user.email || "User"}
|
||||
src={data.user.avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex flex-1 flex-col items-start min-w-0">
|
||||
<p class="font-heading text-h5 text-white w-full truncate">
|
||||
{data.user.fullName || data.user.email || "User"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center justify-center p-1 rounded-[32px] text-white/60 hover:text-white transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>exit_to_app</span
|
||||
>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main
|
||||
class="bg-night flex flex-1 flex-col gap-8 h-full items-start min-h-0 min-w-0 overflow-auto p-8 rounded-[32px]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white">
|
||||
{m.home_title()}
|
||||
</h2>
|
||||
<p class="font-body text-body text-white/50">
|
||||
{m.home_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="add" onclick={() => (showCreateModal = true)}>
|
||||
{m.btn_new()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-6 py-8">
|
||||
<!-- Pending Invites -->
|
||||
{#if pendingInvites.length > 0}
|
||||
<section id="invites" class="mb-8">
|
||||
<section id="invites" class="w-full shrink-0">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span
|
||||
class="material-symbols-rounded text-primary"
|
||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||
>mail</span
|
||||
>
|
||||
<h2 class="font-heading text-body text-white">
|
||||
Pending Invitations
|
||||
</h2>
|
||||
<h3 class="font-heading text-h3 text-white">
|
||||
{m.home_pending_invitations()}
|
||||
</h3>
|
||||
<span
|
||||
class="text-[11px] px-2 py-0.5 bg-primary/10 text-primary rounded-lg font-body"
|
||||
class="text-body-sm px-2 py-0.5 bg-surface text-primary rounded-[8px] font-body"
|
||||
>
|
||||
{pendingInvites.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"
|
||||
>
|
||||
<div class="flex flex-wrap gap-8 items-start">
|
||||
{#each pendingInvites as invite}
|
||||
<div
|
||||
class="bg-primary/5 border border-primary/20 rounded-2xl p-5 transition-all"
|
||||
<a
|
||||
href="/{invite.org.slug}"
|
||||
class="bg-background flex flex-col items-start overflow-clip rounded-[16px] w-[256px] cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
acceptInvite(invite);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div
|
||||
class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-heading text-body"
|
||||
>
|
||||
{invite.org.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] px-2 py-0.5 bg-primary/10 rounded-lg text-primary capitalize font-body"
|
||||
>{invite.role}</span
|
||||
>
|
||||
</div>
|
||||
<h3
|
||||
class="font-heading text-body-sm text-white mb-1"
|
||||
<div
|
||||
class="h-[80px] w-full bg-primary/10 flex items-center justify-center"
|
||||
>
|
||||
{invite.org.name}
|
||||
</h3>
|
||||
<p class="text-[11px] text-light/30 mb-4 font-body">
|
||||
You've been invited to join this organization
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={acceptingInviteId === invite.id}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors disabled:opacity-50"
|
||||
onclick={() => acceptInvite(invite)}
|
||||
<span
|
||||
class="material-symbols-rounded text-primary"
|
||||
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;"
|
||||
>mail</span
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>{acceptingInviteId === invite.id
|
||||
? "hourglass_empty"
|
||||
: "check"}</span
|
||||
>
|
||||
{acceptingInviteId === invite.id
|
||||
? "Joining..."
|
||||
: "Accept"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-4 items-start justify-center px-4 py-5 w-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-start justify-between w-full"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary font-heading text-h5"
|
||||
>
|
||||
{invite.org.name
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<span
|
||||
class="bg-surface flex items-center justify-center p-1 rounded-[8px] font-body text-body-md text-white capitalize"
|
||||
>{invite.role}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<p class="font-heading text-h5 text-white">
|
||||
{invite.org.name}
|
||||
</p>
|
||||
<p
|
||||
class="font-body text-body-md text-white/50"
|
||||
>
|
||||
{acceptingInviteId === invite.id
|
||||
? m.home_invite_joining()
|
||||
: m.home_invite_click_accept()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="font-heading text-h3 text-white">
|
||||
Your Organizations
|
||||
</h2>
|
||||
<p class="text-body-sm text-light/40 mt-1">
|
||||
Select an organization to get started
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-2 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors"
|
||||
onclick={() => (showCreateModal = true)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>add</span
|
||||
>
|
||||
New Organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if organizations.length === 0}
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-2xl p-12 text-center"
|
||||
>
|
||||
<!-- Org Card Grid -->
|
||||
<div
|
||||
class="flex flex-1 flex-wrap gap-y-8 gap-x-8 items-start w-full content-start"
|
||||
>
|
||||
{#if organizations.length === 0}
|
||||
<div
|
||||
class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-light/5 flex items-center justify-center"
|
||||
class="bg-background rounded-[16px] p-12 text-center w-full"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light/20"
|
||||
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 28;"
|
||||
>groups</span
|
||||
<div
|
||||
class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-white/5 flex items-center justify-center"
|
||||
>
|
||||
</div>
|
||||
<h3 class="font-heading text-body text-white mb-1">
|
||||
No organizations yet
|
||||
</h3>
|
||||
<p class="text-body-sm text-light/40 mb-6">
|
||||
Create your first organization to start collaborating
|
||||
</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors"
|
||||
onclick={() => (showCreateModal = true)}
|
||||
>Create Organization</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{#each organizations as org}
|
||||
<a href="/{org.slug}" class="block group">
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 hover:border-primary/30 rounded-2xl p-5 transition-all h-full"
|
||||
<span
|
||||
class="material-symbols-rounded text-white/20"
|
||||
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 28;"
|
||||
>groups</span
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
</div>
|
||||
<h3 class="font-heading text-h5 text-white mb-1">
|
||||
{m.home_empty_title()}
|
||||
</h3>
|
||||
<p class="font-body text-body-md text-white/50 mb-6">
|
||||
{m.home_empty_desc()}
|
||||
</p>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
{m.org_selector_create()}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
{#each organizations as org}
|
||||
<a
|
||||
href="/{org.slug}"
|
||||
class="bg-background flex flex-col items-start overflow-clip rounded-[16px] w-[256px] cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
||||
>
|
||||
<!-- Card image area -->
|
||||
<div class="h-[144px] w-full relative bg-surface">
|
||||
{#if org.avatar_url}
|
||||
<img
|
||||
src={org.avatar_url}
|
||||
alt={org.name}
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<span
|
||||
class="font-heading text-h1 text-white/10"
|
||||
>
|
||||
{org.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Card info -->
|
||||
<div
|
||||
class="flex flex-col gap-4 items-start justify-center px-4 py-5 w-full"
|
||||
>
|
||||
<div
|
||||
class="flex items-start justify-between w-full"
|
||||
>
|
||||
{#if org.avatar_url}
|
||||
<img
|
||||
src={org.avatar_url}
|
||||
alt={org.name}
|
||||
class="w-10 h-10 rounded-xl object-cover"
|
||||
class="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-heading text-body"
|
||||
class="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-night font-heading text-h5"
|
||||
>
|
||||
{org.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<span
|
||||
class="text-[10px] px-2 py-0.5 bg-light/5 rounded-lg text-light/40 capitalize font-body"
|
||||
class="bg-surface flex items-center justify-center p-1 rounded-[8px] font-body text-body-md text-white capitalize"
|
||||
>{org.role}</span
|
||||
>
|
||||
</div>
|
||||
<h3
|
||||
class="font-heading text-body-sm text-white group-hover:text-primary transition-colors"
|
||||
>
|
||||
{org.name}
|
||||
</h3>
|
||||
<p
|
||||
class="text-[11px] text-light/30 mt-0.5 font-body"
|
||||
>
|
||||
/{org.slug}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<p class="font-heading text-h5 text-white">
|
||||
{org.name}
|
||||
</p>
|
||||
<p class="font-body text-body-md text-white/50">
|
||||
/{org.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => (showCreateModal = false)}
|
||||
title="Create Organization"
|
||||
title={m.home_create_org_title()}
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="org-name" class="text-body-sm text-light/60 font-body"
|
||||
>Organization Name</label
|
||||
>
|
||||
<input
|
||||
id="org-name"
|
||||
type="text"
|
||||
bind:value={newOrgName}
|
||||
placeholder="e.g. Acme Inc"
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
label={m.home_create_org_name_label()}
|
||||
bind:value={newOrgName}
|
||||
placeholder={m.home_create_org_name_placeholder()}
|
||||
required
|
||||
/>
|
||||
{#if newOrgName}
|
||||
<p class="text-body-sm text-light/40">
|
||||
<p class="font-body text-body-md text-white/50 px-3">
|
||||
URL: <span class="text-white font-body"
|
||||
>/{generateSlug(newOrgName)}</span
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
<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={() => (showCreateModal = false)}>Cancel</button
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<Button variant="ghost" onclick={() => (showCreateModal = false)}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
disabled={!newOrgName.trim() || creating}
|
||||
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={handleCreateOrg}
|
||||
loading={creating}
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
{m.btn_create()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Snippet } from "svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { on } from "svelte/events";
|
||||
import { Avatar, Logo } from "$lib/components/ui";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
@@ -75,55 +74,25 @@
|
||||
hasPermission(data.userRole, data.userPermissions, permission);
|
||||
setContext("canAccess", canAccess);
|
||||
|
||||
// Sidebar is always expanded (collapse removed)
|
||||
const sidebarCollapsed = false;
|
||||
|
||||
// User dropdown
|
||||
let showUserMenu = $state(false);
|
||||
let menuContainerEl = $state<HTMLElement | null>(null);
|
||||
|
||||
// Attach click-outside and Escape listeners only while menu is open.
|
||||
// Uses svelte/events 'on' to respect Svelte 5 event delegation order.
|
||||
$effect(() => {
|
||||
if (!showUserMenu) return;
|
||||
|
||||
// Defer so the opening click doesn't immediately close the menu
|
||||
const timer = setTimeout(() => {
|
||||
cleanupClick = on(document, "click", (e: MouseEvent) => {
|
||||
if (
|
||||
menuContainerEl &&
|
||||
!menuContainerEl.contains(e.target as Node)
|
||||
) {
|
||||
showUserMenu = false;
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const cleanupKey = on(document, "keydown", (e: Event) => {
|
||||
if ((e as KeyboardEvent).key === "Escape") showUserMenu = false;
|
||||
});
|
||||
|
||||
let cleanupClick: (() => void) | undefined;
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
cleanupClick?.();
|
||||
cleanupKey();
|
||||
};
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut();
|
||||
goto("/");
|
||||
}
|
||||
|
||||
const navItems = $derived([
|
||||
{
|
||||
href: `/${data.org.slug}`,
|
||||
label: m.nav_home(),
|
||||
icon: "home",
|
||||
exact: true,
|
||||
},
|
||||
...(canAccess("documents.view")
|
||||
? [
|
||||
{
|
||||
href: `/${data.org.slug}/documents`,
|
||||
label: m.nav_files(),
|
||||
label: m.nav_storage(),
|
||||
icon: "cloud",
|
||||
exact: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -133,6 +102,7 @@
|
||||
href: `/${data.org.slug}/calendar`,
|
||||
label: m.nav_calendar(),
|
||||
icon: "calendar_today",
|
||||
exact: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -140,6 +110,7 @@
|
||||
href: `/${data.org.slug}/events`,
|
||||
label: m.nav_events(),
|
||||
icon: "celebration",
|
||||
exact: false,
|
||||
},
|
||||
// Chat disabled until fully developed
|
||||
// ...(data.org.feature_chat
|
||||
@@ -157,22 +128,23 @@
|
||||
// },
|
||||
// ]
|
||||
// : []),
|
||||
// Settings requires settings.view or admin role
|
||||
...(canAccess("settings.view")
|
||||
? [
|
||||
{
|
||||
href: `/${data.org.slug}/settings`,
|
||||
label: m.nav_settings(),
|
||||
icon: "settings",
|
||||
exact: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
function isActive(href: string, exact = false): boolean {
|
||||
const navTo = $navigating?.to?.url.pathname;
|
||||
if (navTo) return navTo.startsWith(href);
|
||||
return $page.url.pathname.startsWith(href);
|
||||
const current = navTo || $page.url.pathname;
|
||||
if (exact) return current === href || current === href + "/";
|
||||
return current.startsWith(href);
|
||||
}
|
||||
|
||||
function isNavigatingTo(href: string): boolean {
|
||||
@@ -189,60 +161,62 @@
|
||||
<div class="flex h-screen bg-background p-4 gap-4">
|
||||
<!-- Organization Module -->
|
||||
<aside
|
||||
class="w-64 bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0"
|
||||
class="w-[256px] min-w-[256px] bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0 h-full"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<Logo size="sm" />
|
||||
|
||||
<!-- Org Header -->
|
||||
<a
|
||||
href="/{data.org.slug}"
|
||||
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors"
|
||||
class="flex items-center gap-4 w-full hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div class="shrink-0 w-12 h-12">
|
||||
<div class="shrink-0">
|
||||
<Avatar
|
||||
name={data.org.name}
|
||||
src={data.org.avatar_url}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h1
|
||||
class="font-heading text-h3 text-white truncate whitespace-nowrap"
|
||||
>
|
||||
<div class="min-w-0 flex-1 flex flex-col gap-1">
|
||||
<p class="font-heading text-h4 text-white truncate w-full">
|
||||
{data.org.name}
|
||||
</h1>
|
||||
<p
|
||||
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
|
||||
>
|
||||
</p>
|
||||
<p class="font-body text-body text-white capitalize w-full">
|
||||
{data.userRole}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Nav Items -->
|
||||
<nav class="flex-1 flex flex-col gap-1">
|
||||
<nav class="flex-1 flex flex-col gap-1 min-h-0">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] transition-colors {isActive(
|
||||
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[32px] transition-colors overflow-clip {isActive(
|
||||
item.href,
|
||||
item.exact,
|
||||
)
|
||||
? 'bg-primary'
|
||||
: 'hover:bg-dark'}"
|
||||
: 'hover:bg-white/5'}"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 flex items-center justify-center p-1 shrink-0"
|
||||
>
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded {isActive(item.href)
|
||||
class="material-symbols-rounded {isActive(
|
||||
item.href,
|
||||
item.exact,
|
||||
)
|
||||
? 'text-background'
|
||||
: 'text-light'}"
|
||||
: 'text-white'}"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span
|
||||
class="font-body text-body truncate whitespace-nowrap {isActive(
|
||||
class="flex-1 font-body text-body truncate {isActive(
|
||||
item.href,
|
||||
item.exact,
|
||||
)
|
||||
? 'text-background'
|
||||
: 'text-white'}">{item.label}</span
|
||||
@@ -258,97 +232,37 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User Section + Logo at bottom -->
|
||||
<div class="mt-auto flex flex-col gap-3">
|
||||
<!-- User Avatar + Quick Menu -->
|
||||
<div
|
||||
class="relative user-menu-container"
|
||||
bind:this={menuContainerEl}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors w-full"
|
||||
onclick={() => (showUserMenu = !showUserMenu)}
|
||||
aria-expanded={showUserMenu}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<div class="shrink-0 w-10 h-10">
|
||||
<Avatar
|
||||
name={data.profile.full_name || data.profile.email}
|
||||
src={data.profile.avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 overflow-hidden text-left">
|
||||
<p
|
||||
class="font-body text-body-sm text-white truncate whitespace-nowrap leading-tight"
|
||||
>
|
||||
{data.profile.full_name || "User"}
|
||||
</p>
|
||||
<p
|
||||
class="font-body text-[11px] text-light/50 truncate whitespace-nowrap leading-tight"
|
||||
>
|
||||
{data.profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if showUserMenu}
|
||||
<div
|
||||
class="absolute bottom-full left-0 mb-2 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[200px] z-50"
|
||||
>
|
||||
<a
|
||||
href="/{data.org.slug}/account"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
|
||||
onclick={() => (showUserMenu = false)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light/50"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
person
|
||||
</span>
|
||||
<span>{m.user_menu_account_settings()}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
|
||||
onclick={() => (showUserMenu = false)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light/50"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
swap_horiz
|
||||
</span>
|
||||
<span>{m.user_menu_switch_org()}</span>
|
||||
</a>
|
||||
<div class="border-t border-light/10 my-1"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-error/60"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>
|
||||
logout
|
||||
</span>
|
||||
<span>{m.user_menu_logout()}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<!-- Profile footer -->
|
||||
<div class="flex gap-4 items-center w-full">
|
||||
<a
|
||||
href="/"
|
||||
title="Back to organizations"
|
||||
class="flex items-center justify-center"
|
||||
href="/{data.org.slug}/account"
|
||||
class="flex flex-1 gap-4 items-center min-w-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Logo size="md" />
|
||||
<div class="shrink-0">
|
||||
<Avatar
|
||||
name={data.profile.full_name || data.profile.email}
|
||||
src={data.profile.avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col items-start min-w-0">
|
||||
<p class="font-heading text-h5 text-white w-full truncate">
|
||||
{data.profile.full_name || "User"}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 rounded-[32px] shrink-0 text-white/60 hover:text-white transition-colors"
|
||||
onclick={handleLogout}
|
||||
title="Sign out"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>exit_to_app</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -79,13 +79,13 @@
|
||||
<title>{data.org.name} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full overflow-auto">
|
||||
<div class="flex flex-col gap-8 h-full p-8 overflow-auto">
|
||||
<PageHeader title={data.org.name} subtitle={m.overview_subtitle()}>
|
||||
{#snippet actions()}
|
||||
{#if isEditor}
|
||||
<a
|
||||
href="/{data.org.slug}/events"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
|
||||
class="flex items-center gap-2 px-5 py-2.5 bg-primary text-background rounded-[32px] font-body text-body hover:bg-primary-hover transition-colors"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
@@ -98,9 +98,9 @@
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="flex-1 overflow-auto">
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{#await data.stats}
|
||||
{#each Array(4) as _}
|
||||
<Skeleton
|
||||
@@ -145,7 +145,7 @@
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Left Column: Upcoming Events + Activity -->
|
||||
<div class="lg:col-span-2 flex flex-col gap-6">
|
||||
<!-- Upcoming Events -->
|
||||
@@ -260,24 +260,24 @@
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/{data.org.slug}/settings"
|
||||
class="flex items-center gap-3 bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-4 transition-all group"
|
||||
class="flex items-center gap-4 bg-surface rounded-[32px] p-5 transition-all hover:ring-1 hover:ring-white/10 group"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-light/5 flex items-center justify-center"
|
||||
class="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-light/40 group-hover:text-white transition-colors"
|
||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||
class="material-symbols-rounded text-white/50 group-hover:text-white transition-colors"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>settings</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p
|
||||
class="text-body-sm text-white group-hover:text-primary transition-colors"
|
||||
class="text-body font-heading text-white group-hover:text-primary transition-colors"
|
||||
>
|
||||
{m.nav_settings()}
|
||||
</p>
|
||||
<p class="text-[11px] text-light/30">
|
||||
<p class="text-body-sm font-body text-white/40">
|
||||
{m.settings_general_title()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -277,55 +277,69 @@
|
||||
<title>Account Settings | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Profile Section -->
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5"
|
||||
>
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.account_profile()}
|
||||
<div class="flex-1 flex flex-col gap-8 h-full overflow-auto p-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white">
|
||||
{m.account_settings_title?.() ?? "Your Settings"}
|
||||
</h2>
|
||||
<p class="font-body text-body text-white/50">
|
||||
{m.account_settings_subtitle?.() ??
|
||||
"Manage your settings here."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="font-body text-body-sm text-light/60"
|
||||
>{m.account_photo()}</span
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Settings panels -->
|
||||
<div class="flex flex-1 gap-8 items-start min-h-0 overflow-clip w-full">
|
||||
<!-- Details panel -->
|
||||
<div
|
||||
class="bg-background flex flex-1 flex-col h-full items-start min-h-0 min-w-0 overflow-clip rounded-[32px]"
|
||||
>
|
||||
<!-- Panel header -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center pb-4 pt-5 px-4 rounded-[32px] shrink-0 w-full"
|
||||
>
|
||||
<div class="flex flex-1 items-center min-w-0">
|
||||
<h3
|
||||
class="font-heading text-h3 text-white overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{m.account_profile()}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel content -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-8 items-start min-h-0 overflow-auto p-4 w-full"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="flex gap-4 items-center">
|
||||
<Avatar
|
||||
name={fullName || data.profile.email}
|
||||
src={avatarUrl}
|
||||
size="xl"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={avatarInput}
|
||||
onchange={handleAvatarUpload}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => avatarInput?.click()}
|
||||
loading={isUploading}
|
||||
>
|
||||
{m.btn_upload()}
|
||||
</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onclick={syncGoogleAvatar}
|
||||
>
|
||||
{m.account_sync_google()}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={avatarInput}
|
||||
onchange={handleAvatarUpload}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => avatarInput?.click()}
|
||||
loading={isUploading}
|
||||
>
|
||||
{m.btn_upload()}
|
||||
</Button>
|
||||
{#if avatarUrl}
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={removeAvatar}
|
||||
>
|
||||
@@ -334,59 +348,101 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<Input
|
||||
label={m.account_display_name()}
|
||||
bind:value={fullName}
|
||||
placeholder={m.account_display_name_placeholder()}
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Email (read-only) -->
|
||||
<Input
|
||||
label={m.account_email()}
|
||||
value={data.profile.email}
|
||||
disabled
|
||||
required
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<Input
|
||||
type="password"
|
||||
label={m.account_password()}
|
||||
value="••••••••••"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={async () => {
|
||||
const { error } =
|
||||
await supabase.auth.resetPasswordForEmail(
|
||||
data.profile.email,
|
||||
{
|
||||
redirectTo: `${window.location.origin}/${data.org.slug}/account`,
|
||||
},
|
||||
);
|
||||
if (error) toasts.error(m.toast_error_reset_email());
|
||||
else toasts.success(m.toast_success_reset_email());
|
||||
}}
|
||||
>
|
||||
{m.account_send_reset()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<Input
|
||||
label={m.account_display_name()}
|
||||
bind:value={fullName}
|
||||
placeholder={m.account_display_name_placeholder()}
|
||||
/>
|
||||
|
||||
<!-- Email (read-only) -->
|
||||
<Input
|
||||
label={m.account_email()}
|
||||
value={data.profile.email}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button onclick={saveProfile} loading={isSaving}>
|
||||
<!-- Panel CTA -->
|
||||
<div
|
||||
class="bg-background flex flex-col items-start pb-5 pt-4 px-4 shrink-0 w-full"
|
||||
>
|
||||
<Button fullWidth onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_profile()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Sizing Section -->
|
||||
<!-- Member Information panel -->
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5"
|
||||
class="bg-background flex flex-1 flex-col h-full items-start min-h-0 min-w-0 overflow-clip rounded-[32px]"
|
||||
>
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.account_contact_info()}
|
||||
</h2>
|
||||
<!-- Panel header -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center pb-4 pt-5 px-4 rounded-[32px] shrink-0 w-full"
|
||||
>
|
||||
<div class="flex flex-1 items-center min-w-0">
|
||||
<h3
|
||||
class="font-heading text-h3 text-white overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{m.account_contact_info()}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={m.account_phone()}
|
||||
bind:value={phone}
|
||||
placeholder={m.account_phone_placeholder()}
|
||||
/>
|
||||
<!-- Panel content -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-8 items-start min-h-0 overflow-auto p-4 w-full"
|
||||
>
|
||||
<Input
|
||||
label={m.account_discord()}
|
||||
bind:value={discordHandle}
|
||||
placeholder={m.account_discord_placeholder()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={m.account_discord()}
|
||||
bind:value={discordHandle}
|
||||
placeholder={m.account_discord_placeholder()}
|
||||
/>
|
||||
<Input
|
||||
label={m.account_phone()}
|
||||
bind:value={phone}
|
||||
placeholder={m.account_phone_placeholder()}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
variant="compact"
|
||||
label={m.account_shirt_size()}
|
||||
bind:value={shirtSize}
|
||||
placeholder={m.account_size_placeholder()}
|
||||
options={clothingSizes.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
variant="compact"
|
||||
label={m.account_hoodie_size()}
|
||||
bind:value={hoodieSize}
|
||||
placeholder={m.account_size_placeholder()}
|
||||
@@ -394,121 +450,76 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onclick={saveProfile} loading={isSaving}>
|
||||
<!-- Panel CTA -->
|
||||
<div
|
||||
class="bg-background flex flex-col items-start pb-5 pt-4 px-4 shrink-0 w-full"
|
||||
>
|
||||
<Button fullWidth onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_profile()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<!-- Appearance panel -->
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5"
|
||||
class="bg-background flex flex-1 flex-col h-full items-start min-h-0 min-w-0 overflow-clip rounded-[32px]"
|
||||
>
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.account_appearance()}
|
||||
</h2>
|
||||
|
||||
<!-- Theme -->
|
||||
<Select
|
||||
label={m.account_theme()}
|
||||
bind:value={theme}
|
||||
placeholder=""
|
||||
options={[
|
||||
{ value: "dark", label: m.account_theme_dark() },
|
||||
{ value: "light", label: m.account_theme_light() },
|
||||
{ value: "system", label: m.account_theme_system() },
|
||||
]}
|
||||
/>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-body text-body-sm text-light/60"
|
||||
>{m.account_language()}</span
|
||||
>
|
||||
<p class="font-body text-[11px] text-light/40">
|
||||
{m.account_language_desc()}
|
||||
</p>
|
||||
<div class="flex gap-2 mt-1">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors {currentLocale ===
|
||||
locale
|
||||
? 'bg-primary text-background'
|
||||
: 'bg-light/5 text-light/50 hover:bg-light/10'}"
|
||||
onclick={() => handleLanguageChange(locale)}
|
||||
>
|
||||
{localeLabels[locale] ?? locale}
|
||||
</button>
|
||||
{/each}
|
||||
<!-- Panel header -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center pb-4 pt-5 px-4 rounded-[32px] shrink-0 w-full"
|
||||
>
|
||||
<div class="flex flex-1 items-center min-w-0">
|
||||
<h3
|
||||
class="font-heading text-h3 text-white overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{m.account_appearance()}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onclick={savePreferences} loading={isSaving}>
|
||||
<!-- Panel content -->
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-8 items-start min-h-0 overflow-auto p-4 w-full"
|
||||
>
|
||||
<!-- Theme -->
|
||||
<Select
|
||||
label={m.account_theme()}
|
||||
bind:value={theme}
|
||||
placeholder=""
|
||||
options={[
|
||||
{ value: "dark", label: m.account_theme_dark() },
|
||||
{ value: "light", label: m.account_theme_light() },
|
||||
{ value: "system", label: m.account_theme_system() },
|
||||
]}
|
||||
/>
|
||||
|
||||
<!-- Language -->
|
||||
<Select
|
||||
label={m.account_language()}
|
||||
bind:value={currentLocale}
|
||||
placeholder=""
|
||||
options={locales.map((l) => ({
|
||||
value: l,
|
||||
label: localeLabels[l] ?? l,
|
||||
}))}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement)?.value;
|
||||
if (val)
|
||||
handleLanguageChange(
|
||||
val as (typeof locales)[number],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Panel CTA -->
|
||||
<div
|
||||
class="bg-background flex flex-col items-start pb-5 pt-4 px-4 shrink-0 w-full"
|
||||
>
|
||||
<Button fullWidth onclick={savePreferences} loading={isSaving}>
|
||||
{m.account_save_preferences()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security & Sessions Section -->
|
||||
<div
|
||||
class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5"
|
||||
>
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.account_security()}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="font-body text-body-sm text-white">
|
||||
{m.account_password()}
|
||||
</p>
|
||||
<p class="font-body text-[11px] text-light/40">
|
||||
{m.account_password_desc()}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={async () => {
|
||||
const { error } =
|
||||
await supabase.auth.resetPasswordForEmail(
|
||||
data.profile.email,
|
||||
{
|
||||
redirectTo: `${window.location.origin}/${data.org.slug}/account`,
|
||||
},
|
||||
);
|
||||
if (error)
|
||||
toasts.error(m.toast_error_reset_email());
|
||||
else toasts.success(m.toast_success_reset_email());
|
||||
}}
|
||||
>
|
||||
{m.account_send_reset()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-light/5 pt-4 flex flex-col gap-2">
|
||||
<p class="font-body text-body-sm text-white">
|
||||
{m.account_active_sessions()}
|
||||
</p>
|
||||
<p class="font-body text-[11px] text-light/40">
|
||||
{m.account_sessions_desc()}
|
||||
</p>
|
||||
<div class="mt-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={async () => {
|
||||
await supabase.auth.signOut({ scope: "others" });
|
||||
toasts.success(m.toast_success_signout_others());
|
||||
}}
|
||||
>
|
||||
{m.account_signout_others()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -462,56 +462,63 @@
|
||||
<title>{m.calendar_title()} - {data.org.name} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0"
|
||||
>
|
||||
<div class="flex-1"></div>
|
||||
<Button size="sm" onclick={() => handleDateClick(new Date())}
|
||||
>{m.btn_new()}</Button
|
||||
>
|
||||
<ContextMenu
|
||||
items={[
|
||||
...(isOrgCalendarConnected
|
||||
? [
|
||||
{
|
||||
label: m.calendar_subscribe(),
|
||||
icon: "add",
|
||||
onclick: subscribeToCalendar,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: m.calendar_refresh(),
|
||||
icon: "refresh",
|
||||
onclick: () => {
|
||||
loadGoogleCalendarEvents();
|
||||
},
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
label: "",
|
||||
icon: "",
|
||||
onclick: () => {},
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: m.calendar_settings(),
|
||||
icon: "settings",
|
||||
onclick: () => {
|
||||
window.location.href = `/${data.org.slug}/settings?tab=integrations`;
|
||||
<div class="flex flex-col gap-8 h-full p-8 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white">
|
||||
{m.calendar_title()}
|
||||
</h2>
|
||||
<p class="font-body text-body text-white/50">
|
||||
{m.calendar_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button icon="add" onclick={() => handleDateClick(new Date())}
|
||||
>{m.btn_new()}</Button
|
||||
>
|
||||
<ContextMenu
|
||||
items={[
|
||||
...(isOrgCalendarConnected
|
||||
? [
|
||||
{
|
||||
label: m.calendar_subscribe(),
|
||||
icon: "add",
|
||||
onclick: subscribeToCalendar,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: m.calendar_refresh(),
|
||||
icon: "refresh",
|
||||
onclick: () => {
|
||||
loadGoogleCalendarEvents();
|
||||
},
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
label: "",
|
||||
icon: "",
|
||||
onclick: () => {},
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: m.calendar_settings(),
|
||||
icon: "settings",
|
||||
onclick: () => {
|
||||
window.location.href = `/${data.org.slug}/settings?tab=integrations`;
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<Calendar
|
||||
events={allEvents}
|
||||
onDateClick={handleDateClick}
|
||||
@@ -791,9 +798,7 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showEventFormModal = false)}
|
||||
<Button variant="ghost" onclick={() => (showEventFormModal = false)}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { FileBrowser } from "$lib/components/documents";
|
||||
import { ContentHeader } from "$lib/components/ui";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -17,13 +19,24 @@
|
||||
$effect(() => {
|
||||
documents = data.documents;
|
||||
});
|
||||
|
||||
let fileBrowserRef:
|
||||
| { handleAdd: () => void; handleUpload: () => void }
|
||||
| undefined;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Files - {data.org.name} | root</title>
|
||||
<title>{m.nav_storage()} - {data.org.name} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full p-6">
|
||||
<div class="flex flex-col gap-8 h-full p-8 overflow-auto">
|
||||
<ContentHeader
|
||||
title={m.nav_storage()}
|
||||
subtitle={m.files_subtitle()}
|
||||
actionLabel={m.btn_new()}
|
||||
actionIcon="add"
|
||||
onAction={() => fileBrowserRef?.handleAdd()}
|
||||
/>
|
||||
<FileBrowser
|
||||
org={data.org}
|
||||
bind:documents
|
||||
|
||||
@@ -664,9 +664,9 @@
|
||||
<title>{data.document.name} - {data.org.name} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
||||
<div class="flex flex-col h-full p-8 gap-8">
|
||||
{#if data.isKanban}
|
||||
<!-- Kanban: needs its own header since DocumentViewer is for documents -->
|
||||
<!-- Kanban header -->
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
@@ -674,10 +674,15 @@
|
||||
bind:this={fileInput}
|
||||
onchange={handleJsonImport}
|
||||
/>
|
||||
<header class="flex items-center gap-2 p-1">
|
||||
<h1 class="flex-1 font-heading text-h1 text-white truncate">
|
||||
{data.document.name}
|
||||
</h1>
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white truncate w-full">
|
||||
{data.document.name}
|
||||
</h2>
|
||||
<p class="font-body text-body text-white/50">
|
||||
{data.document.type}
|
||||
</p>
|
||||
</div>
|
||||
<ContextMenu
|
||||
items={[
|
||||
{
|
||||
@@ -698,7 +703,61 @@
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-y-2 w-full shrink-0"
|
||||
>
|
||||
<nav class="flex items-center">
|
||||
<a
|
||||
href="/{data.org.slug}/documents"
|
||||
class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>cloud</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="font-heading text-h3 text-white whitespace-nowrap"
|
||||
>Home</span
|
||||
>
|
||||
</a>
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_forward</span
|
||||
>
|
||||
</span>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>view_kanban</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="font-heading text-h3 text-white whitespace-nowrap"
|
||||
>{data.document.name}</span
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 shrink-0 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>list</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<div class="h-full">
|
||||
@@ -718,31 +777,137 @@
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30 animate-spin mb-4"
|
||||
class="material-symbols-rounded text-white/30 animate-spin mb-4"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
<p class="text-light/50">Loading board...</p>
|
||||
<p class="font-body text-body text-white/50">
|
||||
Loading board...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Document Editor: use shared DocumentViewer component -->
|
||||
<DocumentViewer
|
||||
document={data.document}
|
||||
onSave={handleSave}
|
||||
mode="edit"
|
||||
locked={lockInfo.isLocked && !lockInfo.isOwnLock}
|
||||
lockedByName={lockInfo.lockedByName}
|
||||
/>
|
||||
<!-- Document header -->
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white truncate w-full">
|
||||
{data.document.name}
|
||||
</h2>
|
||||
<p class="font-body text-body text-white/50">
|
||||
{data.document.type}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 rounded-full hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>more_horiz</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-y-2 w-full shrink-0"
|
||||
>
|
||||
<nav class="flex items-center">
|
||||
<a
|
||||
href="/{data.org.slug}/documents"
|
||||
class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>cloud</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="font-heading text-h3 text-white whitespace-nowrap"
|
||||
>Home</span
|
||||
>
|
||||
</a>
|
||||
{#if data.document.parent_id}
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_forward</span
|
||||
>
|
||||
</span>
|
||||
<a
|
||||
href="/{data.org.slug}/documents/folder/{data.document
|
||||
.parent_id}"
|
||||
class="flex items-center gap-2 shrink-0 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>folder</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="font-heading text-h3 text-white whitespace-nowrap"
|
||||
>...</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>chevron_forward</span
|
||||
>
|
||||
</span>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="flex items-center justify-center p-1">
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>description</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="font-heading text-h3 text-white whitespace-nowrap"
|
||||
>{data.document.name}</span
|
||||
>
|
||||
</div>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 shrink-0 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-white"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>list</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Document Editor -->
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<DocumentViewer
|
||||
document={data.document}
|
||||
onSave={handleSave}
|
||||
mode="edit"
|
||||
locked={lockInfo.isLocked && !lockInfo.isOwnLock}
|
||||
lockedByName={lockInfo.lockedByName}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status Bar -->
|
||||
{#if isSaving}
|
||||
<div class="text-body-sm text-light/50">Saving...</div>
|
||||
<div class="font-body text-body-sm text-white/50">Saving...</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -803,7 +968,7 @@
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={() => (showAddColumnModal = false)}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { FileBrowser } from "$lib/components/documents";
|
||||
import { ContentHeader } from "$lib/components/ui";
|
||||
import type { Document } from "$lib/supabase/types";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -25,7 +27,13 @@
|
||||
<title>{data.folder.name} - {data.org.name} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-full p-4 lg:p-5">
|
||||
<div class="flex flex-col gap-8 h-full p-8 overflow-auto">
|
||||
<ContentHeader
|
||||
title={m.nav_storage()}
|
||||
subtitle={m.files_subtitle()}
|
||||
actionLabel={m.btn_new()}
|
||||
actionIcon="add"
|
||||
/>
|
||||
<FileBrowser
|
||||
org={data.org}
|
||||
bind:documents
|
||||
|
||||
@@ -176,43 +176,39 @@
|
||||
<title>{m.events_title()} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Toolbar: Status Tabs + Create Button -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each statusTabs as tab}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
|
||||
tab.value
|
||||
? 'bg-primary text-background'
|
||||
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||
onclick={() => switchStatus(tab.value)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>{tab.icon}</span
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="flex flex-col gap-8 h-full p-8 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white">{m.events_title()}</h2>
|
||||
</div>
|
||||
{#if isEditor}
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onclick={() => (showCreateModal = true)}
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{#if isEditor}
|
||||
<Button icon="add" onclick={() => (showCreateModal = true)}>
|
||||
{m.events_new()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Tabs -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{#each statusTabs as tab}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-4 py-2 rounded-[32px] text-body font-body transition-colors {data.statusFilter ===
|
||||
tab.value
|
||||
? 'bg-surface text-white'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/5'}"
|
||||
onclick={() => switchStatus(tab.value)}
|
||||
>
|
||||
{m.events_new()}
|
||||
</Button>
|
||||
{/if}
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Events Grid -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
{#if data.events.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-light/40"
|
||||
|
||||
@@ -679,7 +679,7 @@
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={() => (showCreateBoardModal = false)}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
@@ -712,7 +712,7 @@
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="ghost"
|
||||
onclick={() => (showEditBoardModal = false)}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
@@ -737,9 +737,7 @@
|
||||
placeholder={m.kanban_column_name_placeholder()}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onclick={() => (showAddColumnModal = false)}
|
||||
<Button variant="ghost" onclick={() => (showAddColumnModal = false)}
|
||||
>{m.btn_cancel()}</Button
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -204,11 +204,7 @@
|
||||
>
|
||||
{m.onboarding_save()}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="tertiary"
|
||||
onclick={skipOnboarding}
|
||||
>
|
||||
<Button fullWidth variant="ghost" onclick={skipOnboarding}>
|
||||
{m.onboarding_skip()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -6,44 +6,43 @@
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@theme {
|
||||
/* Colors - Figma Design System */
|
||||
--color-background: #05090f;
|
||||
--color-night: #0A121F;
|
||||
/* Colors - Figma Design System (exact exports) */
|
||||
--color-background: rgba(5, 9, 15, 1);
|
||||
--color-night: rgba(10, 18, 31, 1);
|
||||
--color-dark: #14243E;
|
||||
--color-surface: #0A121F;
|
||||
--color-light: #E5E6F0;
|
||||
--color-text: #FFFFFF;
|
||||
--color-text-muted: rgba(229, 230, 240, 0.5);
|
||||
--color-surface: rgba(15, 28, 46, 1);
|
||||
--color-light: rgba(255, 255, 255, 1);
|
||||
--color-text: rgba(255, 255, 255, 1);
|
||||
--color-text-muted: rgba(255, 255, 255, 0.5);
|
||||
|
||||
/* Brand - Primary */
|
||||
--color-primary: #00A3E0;
|
||||
--color-primary: rgba(0, 163, 224, 1);
|
||||
--color-primary-hover: #33b5e6;
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #33E000;
|
||||
--color-warning: #FFAB00;
|
||||
--color-error: #E03D00;
|
||||
--color-info: #00A3E0;
|
||||
--color-success: rgba(51, 224, 0, 1);
|
||||
--color-warning: rgba(255, 171, 0, 1);
|
||||
--color-error: rgba(224, 61, 0, 1);
|
||||
--color-info: rgba(0, 163, 224, 1);
|
||||
|
||||
/* Typography - Figma Fonts */
|
||||
--font-heading: 'Tilt Warp', sans-serif;
|
||||
--font-body: 'Work Sans', sans-serif;
|
||||
--font-input: 'Inter', sans-serif;
|
||||
--font-input: 'Work Sans', sans-serif;
|
||||
--font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Font Sizes - Figma Text Styles (--text-* → text-* utilities) */
|
||||
/* Headings (heading font) */
|
||||
--text-h1: 32px;
|
||||
--text-h2: 28px;
|
||||
--text-h3: 24px;
|
||||
--text-h4: 20px;
|
||||
/* Font Sizes - Figma Text Styles: headings-buttons */
|
||||
--text-h1: 28px;
|
||||
--text-h2: 24px;
|
||||
--text-h3: 20px;
|
||||
--text-h4: 18px;
|
||||
--text-h5: 16px;
|
||||
--text-h6: 14px;
|
||||
/* Button text (heading font) */
|
||||
--text-btn-lg: 20px;
|
||||
/* Font Sizes - Figma Text Styles: body btn */
|
||||
--text-btn-lg: 18px;
|
||||
--text-btn-md: 16px;
|
||||
--text-btn-sm: 14px;
|
||||
/* Body text (body font) */
|
||||
/* Font Sizes - Figma Text Styles: body */
|
||||
--text-body: 16px;
|
||||
--text-body-md: 14px;
|
||||
--text-body-sm: 12px;
|
||||
|
||||
@@ -97,19 +97,24 @@
|
||||
<title>{mode === "login" ? "Log In" : "Sign Up"} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex justify-center mb-4">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
<h1 class="text-heading-sm font-heading text-white mb-1">
|
||||
{m.app_name()}
|
||||
</h1>
|
||||
<p class="text-body-sm text-light/40">{m.login_subtitle()}</p>
|
||||
<div class="h-screen bg-background flex items-start p-4">
|
||||
<div
|
||||
class="bg-night flex-1 flex flex-col gap-4 min-h-0 h-full items-center justify-center overflow-auto p-8 rounded-[32px]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center">
|
||||
<Logo size="lg" />
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-2xl border border-light/5 p-6">
|
||||
<!-- Tagline -->
|
||||
<p class="font-bold font-body text-h4 text-white text-center">
|
||||
{m.login_subtitle()}
|
||||
</p>
|
||||
|
||||
<!-- Form card -->
|
||||
<div
|
||||
class="bg-background flex flex-col gap-8 items-center overflow-clip p-4 rounded-[32px] w-full max-w-[384px]"
|
||||
>
|
||||
{#if signupSuccess}
|
||||
<div class="text-center py-4">
|
||||
<div
|
||||
@@ -129,7 +134,7 @@
|
||||
{m.login_signup_success_text({ email })}
|
||||
</p>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
variant="secondary"
|
||||
onclick={() => {
|
||||
signupSuccess = false;
|
||||
mode = "login";
|
||||
@@ -140,23 +145,21 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Tab switcher -->
|
||||
<div
|
||||
class="flex items-center gap-1 bg-dark/50 rounded-xl p-1 mb-6"
|
||||
>
|
||||
<div class="flex gap-4 items-start w-full">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-body-sm font-body transition-colors {mode ===
|
||||
class="flex-1 flex items-center justify-center gap-2 min-w-[36px] overflow-clip px-3 py-2 rounded-[32px] font-bold font-body text-btn-sm transition-colors {mode ===
|
||||
'login'
|
||||
? 'bg-primary text-background'
|
||||
: 'text-light/40 hover:text-white'}"
|
||||
? 'bg-primary text-night'
|
||||
: 'bg-night text-white hover:bg-surface'}"
|
||||
onclick={() => (mode = "login")}
|
||||
>
|
||||
{m.login_tab_login()}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-body-sm font-body transition-colors {mode ===
|
||||
class="flex-1 flex items-center justify-center gap-2 min-w-[36px] overflow-clip px-3 py-2 rounded-[32px] font-bold font-body text-btn-sm transition-colors {mode ===
|
||||
'signup'
|
||||
? 'bg-primary text-background'
|
||||
: 'text-light/40 hover:text-white'}"
|
||||
? 'bg-primary text-night'
|
||||
: 'bg-night text-white hover:bg-surface'}"
|
||||
onclick={() => (mode = "signup")}
|
||||
>
|
||||
{m.login_tab_signup()}
|
||||
@@ -165,7 +168,7 @@
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="mb-4 p-3 bg-error/10 border border-error/20 rounded-xl text-error text-body-sm flex items-center gap-2"
|
||||
class="w-full p-3 bg-error/10 border border-error/20 rounded-[20px] text-error text-body-sm flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
@@ -181,16 +184,8 @@
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
class="flex flex-col gap-8 w-full"
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
label={m.login_email_label()}
|
||||
placeholder={m.login_email_placeholder()}
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
|
||||
{#if mode === "signup"}
|
||||
<Input
|
||||
type="text"
|
||||
@@ -201,6 +196,14 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={m.login_email_label()}
|
||||
placeholder={m.login_email_placeholder()}
|
||||
bind:value={email}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label={m.login_password_label()}
|
||||
@@ -209,46 +212,39 @@
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" fullWidth loading={isLoading}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
fullWidth
|
||||
loading={isLoading}
|
||||
>
|
||||
{mode === "login"
|
||||
? m.login_btn_login()
|
||||
: m.login_btn_signup()}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div class="my-5 flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<!-- Separator -->
|
||||
<div
|
||||
class="flex gap-1 items-center justify-center opacity-50 overflow-clip w-full"
|
||||
>
|
||||
<div class="flex-1 h-px bg-white/30"></div>
|
||||
<span
|
||||
class="text-light/30 text-[11px] uppercase tracking-wider"
|
||||
class="font-body text-body-md text-white text-center px-2"
|
||||
>{m.login_or_continue()}</span
|
||||
>
|
||||
<div class="flex-1 h-px bg-light/10"></div>
|
||||
<div class="flex-1 h-px bg-white/30"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-light/10 hover:border-light/20 hover:bg-light/5 transition-all text-body-sm text-white"
|
||||
<!-- OAuth -->
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
fullWidth
|
||||
onclick={() => handleOAuth("google")}
|
||||
>
|
||||
<svg class="w-4 h-4" 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"
|
||||
/>
|
||||
</svg>
|
||||
{m.login_google()}
|
||||
</button>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
29
src/routes/settings/+page.server.ts
Normal file
29
src/routes/settings/+page.server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
if (!session || !user) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
const { data: profile } = await locals.supabase
|
||||
.from('profiles')
|
||||
.select('full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: profile?.full_name ?? user.user_metadata?.full_name ?? null,
|
||||
avatarUrl: profile?.avatar_url ?? null,
|
||||
phone: profile?.phone ?? null,
|
||||
discordHandle: profile?.discord_handle ?? null,
|
||||
shirtSize: profile?.shirt_size ?? null,
|
||||
hoodieSize: profile?.hoodie_size ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
437
src/routes/settings/+page.svelte
Normal file
437
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,437 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Logo, Avatar, Button, Input, Select } from "$lib/components/ui";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
import { getLocale, setLocale, locales } from "$lib/paraglide/runtime.js";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
user: {
|
||||
id: string;
|
||||
email: string | undefined;
|
||||
fullName: string | null;
|
||||
avatarUrl: string | null;
|
||||
phone: string | null;
|
||||
discordHandle: string | null;
|
||||
shirtSize: string | null;
|
||||
hoodieSize: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let fullName = $state(data.user.fullName ?? "");
|
||||
// svelte-ignore state_referenced_locally
|
||||
let avatarUrl = $state(data.user.avatarUrl);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let phone = $state(data.user.phone ?? "");
|
||||
// svelte-ignore state_referenced_locally
|
||||
let discordHandle = $state(data.user.discordHandle ?? "");
|
||||
// svelte-ignore state_referenced_locally
|
||||
let shirtSize = $state(data.user.shirtSize ?? "");
|
||||
// svelte-ignore state_referenced_locally
|
||||
let hoodieSize = $state(data.user.hoodieSize ?? "");
|
||||
|
||||
let isSaving = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let avatarInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
fullName = data.user.fullName ?? "";
|
||||
avatarUrl = data.user.avatarUrl;
|
||||
phone = data.user.phone ?? "";
|
||||
discordHandle = data.user.discordHandle ?? "";
|
||||
shirtSize = data.user.shirtSize ?? "";
|
||||
hoodieSize = data.user.hoodieSize ?? "";
|
||||
});
|
||||
|
||||
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
|
||||
|
||||
let currentLocale = $state<(typeof locales)[number]>(getLocale());
|
||||
const localeLabels: Record<string, string> = {
|
||||
en: "English",
|
||||
et: "Eesti",
|
||||
};
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
full_name: fullName || null,
|
||||
phone: phone || null,
|
||||
discord_handle: discordHandle || null,
|
||||
shirt_size: shirtSize || null,
|
||||
hoodie_size: hoodieSize || null,
|
||||
})
|
||||
.eq("id", data.user.id);
|
||||
|
||||
if (error) throw error;
|
||||
toasts.success(m.account_save_profile());
|
||||
} catch (e) {
|
||||
toasts.error("Failed to save profile.");
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
const ext = file.name.split(".").pop();
|
||||
const path = `avatars/${data.user.id}.${ext}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, file, { upsert: true });
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const {
|
||||
data: { publicUrl },
|
||||
} = supabase.storage.from("avatars").getPublicUrl(path);
|
||||
|
||||
const url = `${publicUrl}?t=${Date.now()}`;
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: url })
|
||||
.eq("id", data.user.id);
|
||||
|
||||
avatarUrl = url;
|
||||
toasts.success("Avatar updated.");
|
||||
} catch (e) {
|
||||
toasts.error("Failed to upload avatar.");
|
||||
} finally {
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({ avatar_url: null })
|
||||
.eq("id", data.user.id);
|
||||
avatarUrl = null;
|
||||
toasts.success(m.account_remove_photo());
|
||||
}
|
||||
|
||||
function handleLanguageChange(newLocale: (typeof locales)[number]) {
|
||||
currentLocale = newLocale;
|
||||
setLocale(newLocale);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut();
|
||||
goto("/login");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.user_settings_title()} | root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="h-screen bg-background flex gap-4 items-start p-4">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="bg-night flex flex-col gap-4 h-full items-start min-w-[256px] overflow-clip px-4 py-5 rounded-[32px] shrink-0 w-[256px]"
|
||||
>
|
||||
<Logo size="sm" />
|
||||
|
||||
<nav class="flex flex-1 flex-col gap-1 items-start w-full min-h-0">
|
||||
{#each [{ href: "/", icon: "apartment", label: m.home_nav_organizations() }, { href: "/settings", icon: "settings", label: m.nav_settings() }] as item}
|
||||
{@const active =
|
||||
item.href === "/"
|
||||
? $page.url.pathname === "/"
|
||||
: $page.url.pathname.startsWith(item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="flex gap-2 items-center w-full overflow-clip pl-1 pr-2 py-1 rounded-[32px] transition-colors {active
|
||||
? 'bg-primary'
|
||||
: 'hover:bg-white/5'}"
|
||||
>
|
||||
<span class="flex items-center p-1 shrink-0">
|
||||
<span
|
||||
class="material-symbols-rounded {active
|
||||
? 'text-background'
|
||||
: 'text-white'}"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>{item.icon}</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
class="flex-1 font-body text-body truncate {active
|
||||
? 'text-background'
|
||||
: 'text-white'}">{item.label}</span
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Profile footer -->
|
||||
<div class="flex gap-4 items-center w-full">
|
||||
<div class="flex flex-1 gap-4 items-center min-w-0">
|
||||
<Avatar
|
||||
name={data.user.fullName || data.user.email || "User"}
|
||||
src={data.user.avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex flex-1 flex-col items-start min-w-0">
|
||||
<p class="font-heading text-h5 text-white w-full truncate">
|
||||
{data.user.fullName || data.user.email || "User"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center p-1 rounded-[32px] text-white/60 hover:text-white transition-colors"
|
||||
title="Sign out"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||
>exit_to_app</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main
|
||||
class="bg-night flex flex-1 flex-col gap-8 h-full items-start min-h-0 min-w-0 overflow-auto p-8 rounded-[32px]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between w-full shrink-0">
|
||||
<div class="flex flex-1 flex-col gap-2 items-start min-w-0">
|
||||
<h2 class="font-heading text-h2 text-white">
|
||||
{m.user_settings_title()}
|
||||
</h2>
|
||||
<p class="font-body text-body text-white/50">
|
||||
{m.user_settings_subtitle()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings panels -->
|
||||
<div class="flex flex-1 gap-8 items-start min-h-0 overflow-clip w-full">
|
||||
<!-- Details panel -->
|
||||
<div
|
||||
class="bg-background flex flex-1 flex-col h-full items-start min-h-0 min-w-0 overflow-clip rounded-[32px]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center pb-4 pt-5 px-4 shrink-0 w-full"
|
||||
>
|
||||
<h3
|
||||
class="font-heading text-h3 text-white overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{m.account_profile()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-8 items-start min-h-0 overflow-auto p-4 w-full"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="flex gap-4 items-center">
|
||||
<Avatar
|
||||
name={fullName || data.user.email || "User"}
|
||||
src={avatarUrl}
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
bind:this={avatarInput}
|
||||
onchange={handleAvatarUpload}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => avatarInput?.click()}
|
||||
loading={isUploading}
|
||||
>
|
||||
{m.btn_upload()}
|
||||
</Button>
|
||||
{#if avatarUrl}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onclick={removeAvatar}
|
||||
>
|
||||
{m.account_remove_photo()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label={m.account_display_name()}
|
||||
bind:value={fullName}
|
||||
placeholder={m.account_display_name_placeholder()}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={m.account_email()}
|
||||
value={data.user.email ?? ""}
|
||||
disabled
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
label={m.account_password()}
|
||||
value="••••••••••"
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={async () => {
|
||||
const { error } =
|
||||
await supabase.auth.resetPasswordForEmail(
|
||||
data.user.email ?? "",
|
||||
{
|
||||
redirectTo: `${window.location.origin}/settings`,
|
||||
},
|
||||
);
|
||||
if (error)
|
||||
toasts.error(m.toast_error_reset_email());
|
||||
else toasts.success(m.toast_success_reset_email());
|
||||
}}
|
||||
>
|
||||
{m.account_send_reset()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-background flex flex-col items-start pb-5 pt-4 px-4 shrink-0 w-full"
|
||||
>
|
||||
<Button fullWidth onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_profile()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Member Information panel -->
|
||||
<div
|
||||
class="bg-background flex flex-1 flex-col h-full items-start min-h-0 min-w-0 overflow-clip rounded-[32px]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center pb-4 pt-5 px-4 shrink-0 w-full"
|
||||
>
|
||||
<h3
|
||||
class="font-heading text-h3 text-white overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{m.account_contact_info()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-8 items-start min-h-0 overflow-auto p-4 w-full"
|
||||
>
|
||||
<Input
|
||||
label={m.account_discord()}
|
||||
bind:value={discordHandle}
|
||||
placeholder={m.account_discord_placeholder()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={m.account_phone()}
|
||||
bind:value={phone}
|
||||
placeholder={m.account_phone_placeholder()}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={m.account_shirt_size()}
|
||||
bind:value={shirtSize}
|
||||
placeholder={m.account_size_placeholder()}
|
||||
options={clothingSizes.map((s) => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label={m.account_hoodie_size()}
|
||||
bind:value={hoodieSize}
|
||||
placeholder={m.account_size_placeholder()}
|
||||
options={clothingSizes.map((s) => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-background flex flex-col items-start pb-5 pt-4 px-4 shrink-0 w-full"
|
||||
>
|
||||
<Button fullWidth onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_profile()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance panel -->
|
||||
<div
|
||||
class="bg-background flex flex-1 flex-col h-full items-start min-h-0 min-w-0 overflow-clip rounded-[32px]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 items-center pb-4 pt-5 px-4 shrink-0 w-full"
|
||||
>
|
||||
<h3
|
||||
class="font-heading text-h3 text-white overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
{m.account_appearance()}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 flex-col gap-8 items-start min-h-0 overflow-auto p-4 w-full"
|
||||
>
|
||||
<Select
|
||||
label={m.account_language()}
|
||||
bind:value={currentLocale}
|
||||
placeholder=""
|
||||
options={locales.map((l) => ({
|
||||
value: l,
|
||||
label: localeLabels[l] ?? l,
|
||||
}))}
|
||||
onchange={(e) => {
|
||||
const val = (e.target as HTMLSelectElement)?.value;
|
||||
if (val)
|
||||
handleLanguageChange(
|
||||
val as (typeof locales)[number],
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-background flex flex-col items-start pb-5 pt-4 px-4 shrink-0 w-full"
|
||||
>
|
||||
<Button fullWidth onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_preferences()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
Reference in New Issue
Block a user