UI redesign vol1

This commit is contained in:
AlacrisDevs
2026-02-28 17:06:03 +02:00
parent 7ab206fe96
commit 2a28d88849
37 changed files with 1722 additions and 954 deletions

View File

@@ -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."
}

View File

@@ -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."
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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?.()}

View File

@@ -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>

View File

@@ -429,7 +429,7 @@
<Spinner />
{:else if hasMore}
<Button
variant="tertiary"
variant="ghost"
size="sm"
icon="expand_more"
onclick={() => loadEntries(false)}

View File

@@ -395,7 +395,7 @@
</Button>
{#if avatarUrl}
<Button
variant="tertiary"
variant="ghost"
size="sm"
onclick={removeAvatar}
>

View File

@@ -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}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">*&nbsp;</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>

View File

@@ -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>

View 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}

View File

@@ -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>

View File

@@ -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()}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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,
},
};
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View 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,
},
};
};

View 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>