feat: UI overhaul - component library + route layouts with instant headers
- Created 11 reusable UI components: PageHeader, SectionCard, StatCard, StatusBadge, TabBar, MemberList, ActivityFeed, EventCard, ContentSkeleton, QuickLinkGrid, ModuleCard - Created route-specific +layout.svelte for documents, calendar, kanban, events, settings, account - Each layout renders PageHeader instantly from parent data, shows ContentSkeleton during navigation - Removed full-page PageSkeleton from parent layout - Refactored all pages to use new components instead of inline markup - Overview page: uses StatCard, SectionCard, EventCard, ActivityFeed, MemberList, QuickLinkGrid - Events list: uses EventCard, Button components - Event detail: uses ModuleCard, SectionCard - Settings/Account/Calendar/Kanban: headers in layouts, toolbars in pages - Added i18n keys for overview page (EN + ET) - 0 errors, 112 tests pass
This commit is contained in:
@@ -320,5 +320,11 @@
|
|||||||
"events_mod_team": "Team",
|
"events_mod_team": "Team",
|
||||||
"events_mod_team_desc": "Team members and shift scheduling",
|
"events_mod_team_desc": "Team members and shift scheduling",
|
||||||
"events_mod_sponsors": "Sponsors",
|
"events_mod_sponsors": "Sponsors",
|
||||||
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables"
|
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
|
||||||
|
"overview_subtitle": "Welcome back. Here's what's happening.",
|
||||||
|
"overview_stat_events": "Events",
|
||||||
|
"overview_upcoming_events": "Upcoming Events",
|
||||||
|
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
|
||||||
|
"overview_view_all_events": "View all events",
|
||||||
|
"overview_more_members": "+{count} more"
|
||||||
}
|
}
|
||||||
@@ -320,5 +320,11 @@
|
|||||||
"events_mod_team": "Meeskond",
|
"events_mod_team": "Meeskond",
|
||||||
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
|
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
|
||||||
"events_mod_sponsors": "Sponsorid",
|
"events_mod_sponsors": "Sponsorid",
|
||||||
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused"
|
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused",
|
||||||
|
"overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.",
|
||||||
|
"overview_stat_events": "Üritused",
|
||||||
|
"overview_upcoming_events": "Tulevased üritused",
|
||||||
|
"overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.",
|
||||||
|
"overview_view_all_events": "Vaata kõiki üritusi",
|
||||||
|
"overview_more_members": "+{count} veel"
|
||||||
}
|
}
|
||||||
132
src/lib/components/ui/ActivityFeed.svelte
Normal file
132
src/lib/components/ui/ActivityFeed.svelte
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string | null;
|
||||||
|
entity_name: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
profiles: {
|
||||||
|
full_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: ActivityEntry[];
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entries, emptyLabel }: Props = $props();
|
||||||
|
|
||||||
|
function getEntityTypeLabel(entityType: string): string {
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
document: m.entity_document,
|
||||||
|
folder: m.entity_folder,
|
||||||
|
kanban_board: m.entity_kanban_board,
|
||||||
|
kanban_card: m.entity_kanban_card,
|
||||||
|
kanban_column: m.entity_kanban_column,
|
||||||
|
member: m.entity_member,
|
||||||
|
role: m.entity_role,
|
||||||
|
invite: m.entity_invite,
|
||||||
|
event: m.entity_event,
|
||||||
|
};
|
||||||
|
return (map[entityType] ?? (() => entityType))();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityIcon(action: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
create: "add_circle",
|
||||||
|
update: "edit",
|
||||||
|
delete: "delete",
|
||||||
|
move: "drive_file_move",
|
||||||
|
rename: "edit_note",
|
||||||
|
};
|
||||||
|
return map[action] ?? "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivityColor(action: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
create: "text-emerald-400",
|
||||||
|
update: "text-blue-400",
|
||||||
|
delete: "text-red-400",
|
||||||
|
move: "text-amber-400",
|
||||||
|
rename: "text-purple-400",
|
||||||
|
};
|
||||||
|
return map[action] ?? "text-light/50";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeAgo(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(dateStr).getTime();
|
||||||
|
const diffMs = now - then;
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMin < 1) return m.activity_just_now();
|
||||||
|
if (diffMin < 60)
|
||||||
|
return m.activity_minutes_ago({ count: String(diffMin) });
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
return m.activity_days_ago({ count: String(diffDay) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescription(entry: ActivityEntry): string {
|
||||||
|
const userName =
|
||||||
|
entry.profiles?.full_name || entry.profiles?.email || "Someone";
|
||||||
|
const entityType = getEntityTypeLabel(entry.entity_type);
|
||||||
|
const name = entry.entity_name ?? "—";
|
||||||
|
|
||||||
|
const map: Record<string, () => string> = {
|
||||||
|
create: () =>
|
||||||
|
m.activity_created({ user: userName, entityType, name }),
|
||||||
|
update: () =>
|
||||||
|
m.activity_updated({ user: userName, entityType, name }),
|
||||||
|
delete: () =>
|
||||||
|
m.activity_deleted({ user: userName, entityType, name }),
|
||||||
|
move: () => m.activity_moved({ user: userName, entityType, name }),
|
||||||
|
rename: () =>
|
||||||
|
m.activity_renamed({ user: userName, entityType, name }),
|
||||||
|
};
|
||||||
|
return (map[entry.action] ?? map["update"]!)();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if entries.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center text-light/40 py-8"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded mb-2"
|
||||||
|
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
|
||||||
|
>history</span
|
||||||
|
>
|
||||||
|
<p class="text-body-sm">{emptyLabel ?? m.activity_empty()}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#each entries as entry}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 px-3 py-2 rounded-xl hover:bg-dark/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded {getActivityColor(
|
||||||
|
entry.action,
|
||||||
|
)} mt-0.5 shrink-0"
|
||||||
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
|
>{getActivityIcon(entry.action)}</span
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-body-sm text-light/70 leading-relaxed">
|
||||||
|
{getDescription(entry)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-[11px] text-light/30 shrink-0 mt-0.5"
|
||||||
|
>{formatTimeAgo(entry.created_at)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
109
src/lib/components/ui/ContentSkeleton.svelte
Normal file
109
src/lib/components/ui/ContentSkeleton.svelte
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Skeleton from "./Skeleton.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: "default" | "kanban" | "files" | "calendar" | "settings" | "list" | "detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
let { variant = "default" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex-1 p-6 animate-in">
|
||||||
|
{#if variant === "kanban"}
|
||||||
|
<div class="flex gap-3 h-full overflow-hidden">
|
||||||
|
{#each Array(3) as _}
|
||||||
|
<div class="flex-shrink-0 w-[256px] bg-dark/20 rounded-xl p-4 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Skeleton variant="text" width="120px" height="1.25rem" />
|
||||||
|
<Skeleton variant="rectangular" width="24px" height="20px" class="rounded-lg" />
|
||||||
|
</div>
|
||||||
|
{#each Array(3) as __}
|
||||||
|
<Skeleton variant="card" height="72px" class="rounded-xl" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if variant === "files"}
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Skeleton variant="text" width="300px" height="2.5rem" class="rounded-xl" />
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Skeleton variant="circular" width="36px" height="36px" />
|
||||||
|
<Skeleton variant="circular" width="36px" height="36px" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||||
|
{#each Array(12) as _}
|
||||||
|
<Skeleton variant="card" height="100px" class="rounded-xl" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if variant === "calendar"}
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<Skeleton variant="circular" width="32px" height="32px" />
|
||||||
|
<Skeleton variant="text" width="200px" height="1.5rem" />
|
||||||
|
<Skeleton variant="circular" width="32px" height="32px" />
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<Skeleton variant="rectangular" width="200px" height="32px" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-1">
|
||||||
|
{#each Array(7) as _}
|
||||||
|
<Skeleton variant="text" width="100%" height="2rem" />
|
||||||
|
{/each}
|
||||||
|
{#each Array(35) as _}
|
||||||
|
<Skeleton variant="rectangular" width="100%" height="72px" class="rounded-none" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if variant === "settings"}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Skeleton variant="text" width="160px" height="1.5rem" />
|
||||||
|
<Skeleton variant="text" lines={3} />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-xl" />
|
||||||
|
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
{:else if variant === "list"}
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<Skeleton variant="card" height="72px" class="rounded-xl" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each Array(5) as _}
|
||||||
|
<Skeleton variant="rectangular" height="64px" class="rounded-xl" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if variant === "detail"}
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="lg:col-span-2 flex flex-col gap-4">
|
||||||
|
<Skeleton variant="card" height="200px" class="rounded-xl" />
|
||||||
|
<Skeleton variant="card" height="300px" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Skeleton variant="card" height="180px" class="rounded-xl" />
|
||||||
|
<Skeleton variant="card" height="120px" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||||
|
{#each Array(4) as _}
|
||||||
|
<Skeleton variant="card" height="72px" class="rounded-xl" />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<Skeleton variant="card" height="300px" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Skeleton variant="card" height="140px" class="rounded-xl" />
|
||||||
|
<Skeleton variant="card" height="200px" class="rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.animate-in {
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
src/lib/components/ui/EventCard.svelte
Normal file
116
src/lib/components/ui/EventCard.svelte
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import StatusBadge from "./StatusBadge.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
color: string | null;
|
||||||
|
venueName: string | null;
|
||||||
|
href: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
status,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
color,
|
||||||
|
venueName,
|
||||||
|
href,
|
||||||
|
compact = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if compact}
|
||||||
|
<!-- Compact variant: single row for lists/sidebars -->
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style="background-color: {color || '#00A3E0'}"
|
||||||
|
></div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
class="text-body-sm text-white group-hover:text-primary transition-colors truncate"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
|
{#if startDate}
|
||||||
|
<span class="text-[11px] text-light/40"
|
||||||
|
>{formatDate(startDate)}{endDate
|
||||||
|
? ` — ${formatDate(endDate)}`
|
||||||
|
: ""}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if venueName}
|
||||||
|
<span class="text-[11px] text-light/30"
|
||||||
|
>· {venueName}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge {status} />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<!-- Full card variant: for grid layouts -->
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-2xl p-5 flex flex-col gap-3 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
style="background-color: {color || '#00A3E0'}"
|
||||||
|
></div>
|
||||||
|
<h3
|
||||||
|
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<StatusBadge {status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 text-[12px] text-light/40">
|
||||||
|
{#if startDate}
|
||||||
|
<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;"
|
||||||
|
>calendar_today</span
|
||||||
|
>
|
||||||
|
{formatDate(startDate)}{endDate
|
||||||
|
? ` — ${formatDate(endDate)}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if venueName}
|
||||||
|
<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;"
|
||||||
|
>location_on</span
|
||||||
|
>
|
||||||
|
{venueName}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
71
src/lib/components/ui/MemberList.svelte
Normal file
71
src/lib/components/ui/MemberList.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Avatar from "./Avatar.svelte";
|
||||||
|
|
||||||
|
interface MemberItem {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
profiles: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: MemberItem[];
|
||||||
|
max?: number;
|
||||||
|
moreHref?: string;
|
||||||
|
moreLabel?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
members,
|
||||||
|
max = 6,
|
||||||
|
moreHref,
|
||||||
|
moreLabel,
|
||||||
|
emptyLabel,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const visible = $derived(members.slice(0, max));
|
||||||
|
const remaining = $derived(members.length - max);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
{#each visible as member}
|
||||||
|
<div class="flex items-center gap-2.5 px-1 py-1">
|
||||||
|
<Avatar
|
||||||
|
name={member.profiles?.full_name ||
|
||||||
|
member.profiles?.email ||
|
||||||
|
"?"}
|
||||||
|
src={member.profiles?.avatar_url}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-body-sm text-white truncate">
|
||||||
|
{member.profiles?.full_name ||
|
||||||
|
member.profiles?.email ||
|
||||||
|
"Unknown"}
|
||||||
|
</p>
|
||||||
|
<p class="text-[11px] text-light/40 capitalize">
|
||||||
|
{member.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if remaining > 0 && moreHref && moreLabel}
|
||||||
|
<a
|
||||||
|
href={moreHref}
|
||||||
|
class="text-body-sm text-primary hover:underline text-center pt-1"
|
||||||
|
>
|
||||||
|
{moreLabel}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if members.length === 0 && emptyLabel}
|
||||||
|
<p class="text-body-sm text-light/30 text-center py-4">
|
||||||
|
{emptyLabel}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
40
src/lib/components/ui/ModuleCard.svelte
Normal file
40
src/lib/components/ui/ModuleCard.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
href: string;
|
||||||
|
color?: string;
|
||||||
|
bg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
href,
|
||||||
|
color = "text-primary",
|
||||||
|
bg = "bg-primary/10",
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
{href}
|
||||||
|
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-xl p-4 flex flex-col gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl {bg} flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded {color}"
|
||||||
|
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||||
|
>{icon}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
class="text-body font-heading text-white group-hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
<p class="text-[12px] text-light/40">{description}</p>
|
||||||
|
</a>
|
||||||
46
src/lib/components/ui/PageHeader.svelte
Normal file
46
src/lib/components/ui/PageHeader.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
icon?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
actions?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
iconColor = "text-white",
|
||||||
|
actions,
|
||||||
|
class: className = "",
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="flex items-center justify-between px-6 py-5 border-b border-light/5 shrink-0 {className}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
{#if icon}
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded {iconColor} shrink-0"
|
||||||
|
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
|
||||||
|
>{icon}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-h1 font-heading text-white truncate">{title}</h1>
|
||||||
|
{#if subtitle}
|
||||||
|
<p class="text-body-sm text-light/50 mt-0.5">{subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if actions}
|
||||||
|
<div class="flex items-center gap-2 shrink-0 ml-4">
|
||||||
|
{@render actions()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
30
src/lib/components/ui/QuickLinkGrid.svelte
Normal file
30
src/lib/components/ui/QuickLinkGrid.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface QuickLink {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
href: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
links: QuickLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { links }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
{#each links as link}
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 transition-all text-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded {link.color ?? 'text-light/50'}"
|
||||||
|
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||||
|
>{link.icon}</span
|
||||||
|
>
|
||||||
|
<span class="text-[12px] text-light/60">{link.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
41
src/lib/components/ui/SectionCard.svelte
Normal file
41
src/lib/components/ui/SectionCard.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
titleRight?: Snippet;
|
||||||
|
padding?: "sm" | "md" | "lg";
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
titleRight,
|
||||||
|
padding = "md",
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const paddingClasses = {
|
||||||
|
sm: "p-3",
|
||||||
|
md: "p-5",
|
||||||
|
lg: "p-6",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-dark/30 border border-light/5 rounded-xl {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>
|
||||||
|
{/if}
|
||||||
|
{#if titleRight}
|
||||||
|
{@render titleRight()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
58
src/lib/components/ui/StatCard.svelte
Normal file
58
src/lib/components/ui/StatCard.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
icon: string;
|
||||||
|
color?: string;
|
||||||
|
bg?: string;
|
||||||
|
href?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
color = "text-primary",
|
||||||
|
bg = "bg-primary/10",
|
||||||
|
href = null,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#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"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl {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;"
|
||||||
|
>{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>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="bg-dark/30 border border-light/5 rounded-xl p-4 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl {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;"
|
||||||
|
>{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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
30
src/lib/components/ui/StatusBadge.svelte
Normal file
30
src/lib/components/ui/StatusBadge.svelte
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
status: string;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
}
|
||||||
|
|
||||||
|
let { status, size = "sm" }: Props = $props();
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
planning: "text-amber-400 bg-amber-400/10",
|
||||||
|
active: "text-emerald-400 bg-emerald-400/10",
|
||||||
|
completed: "text-blue-400 bg-blue-400/10",
|
||||||
|
archived: "text-light/40 bg-light/5",
|
||||||
|
draft: "text-light/40 bg-light/5",
|
||||||
|
sent: "text-amber-400 bg-amber-400/10",
|
||||||
|
signed: "text-emerald-400 bg-emerald-400/10",
|
||||||
|
fulfilled: "text-blue-400 bg-blue-400/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "text-[10px] px-2 py-0.5",
|
||||||
|
md: "text-[12px] px-2.5 py-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = $derived(colorMap[status] ?? "text-light/40 bg-light/5");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="rounded-full capitalize {sizeClasses[size]} {colors}"
|
||||||
|
>{status}</span
|
||||||
|
>
|
||||||
37
src/lib/components/ui/TabBar.svelte
Normal file
37
src/lib/components/ui/TabBar.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Tab {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tabs: Tab[];
|
||||||
|
active: string;
|
||||||
|
onchange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { tabs, active, onchange }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
|
{#each tabs 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 {active ===
|
||||||
|
tab.value
|
||||||
|
? 'bg-primary text-background'
|
||||||
|
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||||
|
onclick={() => onchange(tab.value)}
|
||||||
|
>
|
||||||
|
{#if tab.icon}
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>{tab.icon}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -26,6 +26,17 @@ export { default as Icon } from './Icon.svelte';
|
|||||||
export { default as AssigneePicker } from './AssigneePicker.svelte';
|
export { default as AssigneePicker } from './AssigneePicker.svelte';
|
||||||
export { default as ContextMenu } from './ContextMenu.svelte';
|
export { default as ContextMenu } from './ContextMenu.svelte';
|
||||||
export { default as PageSkeleton } from './PageSkeleton.svelte';
|
export { default as PageSkeleton } from './PageSkeleton.svelte';
|
||||||
|
export { default as PageHeader } from './PageHeader.svelte';
|
||||||
|
export { default as SectionCard } from './SectionCard.svelte';
|
||||||
|
export { default as StatCard } from './StatCard.svelte';
|
||||||
|
export { default as StatusBadge } from './StatusBadge.svelte';
|
||||||
|
export { default as TabBar } from './TabBar.svelte';
|
||||||
|
export { default as MemberList } from './MemberList.svelte';
|
||||||
|
export { default as ActivityFeed } from './ActivityFeed.svelte';
|
||||||
|
export { default as EventCard } from './EventCard.svelte';
|
||||||
|
export { default as ContentSkeleton } from './ContentSkeleton.svelte';
|
||||||
|
export { default as QuickLinkGrid } from './QuickLinkGrid.svelte';
|
||||||
|
export { default as ModuleCard } from './ModuleCard.svelte';
|
||||||
export { default as ImagePreviewModal } from './ImagePreviewModal.svelte';
|
export { default as ImagePreviewModal } from './ImagePreviewModal.svelte';
|
||||||
export { default as Twemoji } from './Twemoji.svelte';
|
export { default as Twemoji } from './Twemoji.svelte';
|
||||||
export { default as EmojiPicker } from './EmojiPicker.svelte';
|
export { default as EmojiPicker } from './EmojiPicker.svelte';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id)
|
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id)
|
||||||
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult] = await Promise.all([
|
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult, eventCountResult] = await Promise.all([
|
||||||
locals.supabase
|
locals.supabase
|
||||||
.from('org_members')
|
.from('org_members')
|
||||||
.select('role, role_id')
|
.select('role, role_id')
|
||||||
@@ -68,7 +68,11 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
.from('documents')
|
.from('documents')
|
||||||
.select('id', { count: 'exact', head: true })
|
.select('id', { count: 'exact', head: true })
|
||||||
.eq('org_id', org.id)
|
.eq('org_id', org.id)
|
||||||
.eq('type', 'kanban')
|
.eq('type', 'kanban'),
|
||||||
|
locals.supabase
|
||||||
|
.from('events')
|
||||||
|
.select('id', { count: 'exact', head: true })
|
||||||
|
.eq('org_id', org.id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { data: membership } = membershipResult;
|
const { data: membership } = membershipResult;
|
||||||
@@ -81,6 +85,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
documentCount: docCountResult.count ?? 0,
|
documentCount: docCountResult.count ?? 0,
|
||||||
folderCount: folderCountResult.count ?? 0,
|
folderCount: folderCountResult.count ?? 0,
|
||||||
kanbanCount: kanbanCountResult.count ?? 0,
|
kanbanCount: kanbanCountResult.count ?? 0,
|
||||||
|
eventCount: eventCountResult.count ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -121,6 +126,15 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
|
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Fetch upcoming events for the overview
|
||||||
|
const { data: upcomingEvents } = await locals.supabase
|
||||||
|
.from('events')
|
||||||
|
.select('id, name, slug, status, start_date, end_date, color, venue_name')
|
||||||
|
.eq('org_id', org.id)
|
||||||
|
.in('status', ['planning', 'active'])
|
||||||
|
.order('start_date', { ascending: true, nullsFirst: false })
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
org,
|
org,
|
||||||
userRole: membership.role,
|
userRole: membership.role,
|
||||||
@@ -128,6 +142,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
members,
|
members,
|
||||||
recentActivity: recentActivity ?? [],
|
recentActivity: recentActivity ?? [],
|
||||||
stats,
|
stats,
|
||||||
|
upcomingEvents: upcomingEvents ?? [],
|
||||||
user,
|
user,
|
||||||
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
|
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page, navigating } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { on } from "svelte/events";
|
import { on } from "svelte/events";
|
||||||
import { Avatar, Logo, PageSkeleton } from "$lib/components/ui";
|
import { Avatar, Logo } from "$lib/components/ui";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
import { hasPermission, type Permission } from "$lib/utils/permissions";
|
import { hasPermission, type Permission } from "$lib/utils/permissions";
|
||||||
@@ -345,23 +345,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
|
<main class="flex-1 bg-night rounded-[32px] overflow-hidden relative">
|
||||||
{#if $navigating}
|
{@render children()}
|
||||||
{@const target = $navigating.to?.url.pathname ?? ""}
|
|
||||||
{@const skeletonVariant = target.includes("/kanban")
|
|
||||||
? "kanban"
|
|
||||||
: target.includes("/documents")
|
|
||||||
? "files"
|
|
||||||
: target.includes("/calendar")
|
|
||||||
? "calendar"
|
|
||||||
: target.includes("/events")
|
|
||||||
? "default"
|
|
||||||
: target.includes("/settings")
|
|
||||||
? "settings"
|
|
||||||
: "default"}
|
|
||||||
<PageSkeleton variant={skeletonVariant} />
|
|
||||||
{:else}
|
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Avatar, Card } from "$lib/components/ui";
|
import {
|
||||||
|
PageHeader,
|
||||||
|
StatCard,
|
||||||
|
SectionCard,
|
||||||
|
EventCard,
|
||||||
|
ActivityFeed,
|
||||||
|
MemberList,
|
||||||
|
QuickLinkGrid,
|
||||||
|
} from "$lib/components/ui";
|
||||||
import * as m from "$lib/paraglide/messages";
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
@@ -15,6 +23,17 @@
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpcomingEvent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
color: string | null;
|
||||||
|
venue_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
@@ -24,8 +43,10 @@
|
|||||||
documentCount: number;
|
documentCount: number;
|
||||||
folderCount: number;
|
folderCount: number;
|
||||||
kanbanCount: number;
|
kanbanCount: number;
|
||||||
|
eventCount: number;
|
||||||
};
|
};
|
||||||
recentActivity: ActivityEntry[];
|
recentActivity: ActivityEntry[];
|
||||||
|
upcomingEvents: UpcomingEvent[];
|
||||||
members: {
|
members: {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -48,321 +69,174 @@
|
|||||||
documentCount: 0,
|
documentCount: 0,
|
||||||
folderCount: 0,
|
folderCount: 0,
|
||||||
kanbanCount: 0,
|
kanbanCount: 0,
|
||||||
|
eventCount: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentActivity = $derived(data.recentActivity ?? []);
|
const recentActivity = $derived(data.recentActivity ?? []);
|
||||||
|
const upcomingEvents = $derived(data.upcomingEvents ?? []);
|
||||||
const members = $derived(data.members ?? []);
|
const members = $derived(data.members ?? []);
|
||||||
|
|
||||||
const isAdmin = $derived(
|
const isAdmin = $derived(
|
||||||
data.userRole === "owner" || data.userRole === "admin",
|
data.userRole === "owner" || data.userRole === "admin",
|
||||||
);
|
);
|
||||||
|
const isEditor = $derived(
|
||||||
const statCards = $derived([
|
["owner", "admin", "editor"].includes(data.userRole),
|
||||||
{
|
);
|
||||||
label: m.overview_stat_members(),
|
|
||||||
value: stats.memberCount,
|
|
||||||
icon: "group",
|
|
||||||
href: isAdmin ? `/${data.org.slug}/settings` : null,
|
|
||||||
color: "text-blue-400",
|
|
||||||
bg: "bg-blue-400/10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: m.overview_stat_documents(),
|
|
||||||
value: stats.documentCount,
|
|
||||||
icon: "description",
|
|
||||||
href: `/${data.org.slug}/documents`,
|
|
||||||
color: "text-emerald-400",
|
|
||||||
bg: "bg-emerald-400/10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: m.overview_stat_folders(),
|
|
||||||
value: stats.folderCount,
|
|
||||||
icon: "folder",
|
|
||||||
href: `/${data.org.slug}/documents`,
|
|
||||||
color: "text-amber-400",
|
|
||||||
bg: "bg-amber-400/10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: m.overview_stat_boards(),
|
|
||||||
value: stats.kanbanCount,
|
|
||||||
icon: "view_kanban",
|
|
||||||
href: `/${data.org.slug}/documents`,
|
|
||||||
color: "text-purple-400",
|
|
||||||
bg: "bg-purple-400/10",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const quickLinks = $derived([
|
const quickLinks = $derived([
|
||||||
{
|
{ label: m.nav_events(), icon: "celebration", href: `/${data.org.slug}/events`, color: "text-primary" },
|
||||||
label: m.nav_files(),
|
{ label: m.nav_files(), icon: "cloud", href: `/${data.org.slug}/documents`, color: "text-emerald-400" },
|
||||||
icon: "cloud",
|
{ label: m.nav_calendar(), icon: "calendar_today", href: `/${data.org.slug}/calendar`, color: "text-blue-400" },
|
||||||
href: `/${data.org.slug}/documents`,
|
{ label: "Chat", icon: "chat", href: `/${data.org.slug}/chat`, color: "text-purple-400" },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: m.nav_calendar(),
|
|
||||||
icon: "calendar_today",
|
|
||||||
href: `/${data.org.slug}/calendar`,
|
|
||||||
},
|
|
||||||
...(isAdmin
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: m.nav_settings(),
|
|
||||||
icon: "settings",
|
|
||||||
href: `/${data.org.slug}/settings`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function getEntityTypeLabel(entityType: string): string {
|
|
||||||
const map: Record<string, () => string> = {
|
|
||||||
document: m.entity_document,
|
|
||||||
folder: m.entity_folder,
|
|
||||||
kanban_board: m.entity_kanban_board,
|
|
||||||
kanban_card: m.entity_kanban_card,
|
|
||||||
kanban_column: m.entity_kanban_column,
|
|
||||||
member: m.entity_member,
|
|
||||||
role: m.entity_role,
|
|
||||||
invite: m.entity_invite,
|
|
||||||
};
|
|
||||||
return (map[entityType] ?? (() => entityType))();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivityIcon(action: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
create: "add_circle",
|
|
||||||
update: "edit",
|
|
||||||
delete: "delete",
|
|
||||||
move: "drive_file_move",
|
|
||||||
rename: "edit_note",
|
|
||||||
};
|
|
||||||
return map[action] ?? "info";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivityColor(action: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
create: "text-emerald-400",
|
|
||||||
update: "text-blue-400",
|
|
||||||
delete: "text-red-400",
|
|
||||||
move: "text-amber-400",
|
|
||||||
rename: "text-purple-400",
|
|
||||||
};
|
|
||||||
return map[action] ?? "text-light/50";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeAgo(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return "";
|
|
||||||
const now = Date.now();
|
|
||||||
const then = new Date(dateStr).getTime();
|
|
||||||
const diffMs = now - then;
|
|
||||||
const diffMin = Math.floor(diffMs / 60000);
|
|
||||||
if (diffMin < 1) return m.activity_just_now();
|
|
||||||
if (diffMin < 60)
|
|
||||||
return m.activity_minutes_ago({ count: String(diffMin) });
|
|
||||||
const diffHr = Math.floor(diffMin / 60);
|
|
||||||
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
|
|
||||||
const diffDay = Math.floor(diffHr / 24);
|
|
||||||
return m.activity_days_ago({ count: String(diffDay) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivityDescription(entry: ActivityEntry): string {
|
|
||||||
const userName =
|
|
||||||
entry.profiles?.full_name || entry.profiles?.email || "Someone";
|
|
||||||
const entityType = getEntityTypeLabel(entry.entity_type);
|
|
||||||
const name = entry.entity_name ?? "—";
|
|
||||||
|
|
||||||
const map: Record<string, () => string> = {
|
|
||||||
create: () =>
|
|
||||||
m.activity_created({ user: userName, entityType, name }),
|
|
||||||
update: () =>
|
|
||||||
m.activity_updated({ user: userName, entityType, name }),
|
|
||||||
delete: () =>
|
|
||||||
m.activity_deleted({ user: userName, entityType, name }),
|
|
||||||
move: () => m.activity_moved({ user: userName, entityType, name }),
|
|
||||||
rename: () =>
|
|
||||||
m.activity_renamed({ user: userName, entityType, name }),
|
|
||||||
};
|
|
||||||
return (map[entry.action] ?? map["update"]!)();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.org.name} | Root</title>
|
<title>{data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-6 overflow-auto">
|
<div class="flex flex-col h-full overflow-auto">
|
||||||
<!-- Header -->
|
<PageHeader title={data.org.name} subtitle={m.overview_subtitle()}>
|
||||||
<header>
|
{#snippet actions()}
|
||||||
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
|
{#if isEditor}
|
||||||
<p class="text-body text-light/60 font-body">{m.overview_title()}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{#each statCards as stat}
|
|
||||||
{#if stat.href}
|
|
||||||
<a
|
<a
|
||||||
href={stat.href}
|
href="/{data.org.slug}/events"
|
||||||
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group"
|
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"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="material-symbols-rounded {stat.color}"
|
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
|
||||||
>
|
|
||||||
{stat.icon}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-bold text-white">
|
|
||||||
{stat.value}
|
|
||||||
</p>
|
|
||||||
<p class="text-body-sm text-light/50">{stat.label}</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
|
|
||||||
<div
|
|
||||||
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="material-symbols-rounded {stat.color}"
|
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
|
||||||
>
|
|
||||||
{stat.icon}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-2xl font-bold text-white">
|
|
||||||
{stat.value}
|
|
||||||
</p>
|
|
||||||
<p class="text-body-sm text-light/50">{stat.label}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
|
|
||||||
<!-- Recent Activity -->
|
|
||||||
<div
|
|
||||||
class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-4 min-h-0"
|
|
||||||
>
|
|
||||||
<h2 class="text-h3 font-heading text-white">
|
|
||||||
{m.activity_title()}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if recentActivity.length === 0}
|
|
||||||
<div
|
|
||||||
class="flex-1 flex flex-col items-center justify-center text-light/40 py-12"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded mb-3"
|
class="material-symbols-rounded"
|
||||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
|
>celebration</span
|
||||||
>
|
>
|
||||||
history
|
{m.nav_events()}
|
||||||
</span>
|
</a>
|
||||||
<p class="text-body">{m.activity_empty()}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-1 overflow-auto flex-1">
|
|
||||||
{#each recentActivity as entry}
|
|
||||||
<div
|
|
||||||
class="flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="material-symbols-rounded {getActivityColor(
|
|
||||||
entry.action,
|
|
||||||
)} mt-0.5 shrink-0"
|
|
||||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
||||||
>
|
|
||||||
{getActivityIcon(entry.action)}
|
|
||||||
</span>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p
|
|
||||||
class="text-body-sm text-light leading-relaxed"
|
|
||||||
>
|
|
||||||
{getActivityDescription(entry)}
|
|
||||||
</p>
|
|
||||||
<p class="text-[11px] text-light/40 mt-0.5">
|
|
||||||
{formatTimeAgo(entry.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex-1 p-6 overflow-auto">
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||||
|
<StatCard
|
||||||
|
label={m.overview_stat_events()}
|
||||||
|
value={stats.eventCount}
|
||||||
|
icon="celebration"
|
||||||
|
href="/{data.org.slug}/events"
|
||||||
|
color="text-primary"
|
||||||
|
bg="bg-primary/10"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={m.overview_stat_members()}
|
||||||
|
value={stats.memberCount}
|
||||||
|
icon="group"
|
||||||
|
href={isAdmin ? `/${data.org.slug}/settings` : null}
|
||||||
|
color="text-blue-400"
|
||||||
|
bg="bg-blue-400/10"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={m.overview_stat_documents()}
|
||||||
|
value={stats.documentCount}
|
||||||
|
icon="description"
|
||||||
|
href="/{data.org.slug}/documents"
|
||||||
|
color="text-emerald-400"
|
||||||
|
bg="bg-emerald-400/10"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={m.overview_stat_boards()}
|
||||||
|
value={stats.kanbanCount}
|
||||||
|
icon="view_kanban"
|
||||||
|
href="/{data.org.slug}/documents"
|
||||||
|
color="text-purple-400"
|
||||||
|
bg="bg-purple-400/10"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar: Quick Links + Members -->
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div class="flex flex-col gap-6">
|
<!-- Left Column: Upcoming Events + Activity -->
|
||||||
<!-- Quick Links -->
|
<div class="lg:col-span-2 flex flex-col gap-6">
|
||||||
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
|
<!-- Upcoming Events -->
|
||||||
<h2 class="text-h3 font-heading text-white">
|
<SectionCard title={m.overview_upcoming_events()}>
|
||||||
{m.overview_quick_links()}
|
{#snippet titleRight()}
|
||||||
</h2>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
{#each quickLinks as link}
|
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href="/{data.org.slug}/events"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
|
class="text-[12px] text-primary hover:underline"
|
||||||
|
>{m.overview_view_all_events()}</a
|
||||||
>
|
>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if upcomingEvents.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center text-light/40 py-8">
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light/50"
|
class="material-symbols-rounded mb-2"
|
||||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
|
||||||
|
>celebration</span
|
||||||
>
|
>
|
||||||
{link.icon}
|
<p class="text-body-sm">{m.overview_upcoming_empty()}</p>
|
||||||
</span>
|
</div>
|
||||||
<span class="text-body">{link.label}</span>
|
{:else}
|
||||||
</a>
|
<div class="flex flex-col gap-1">
|
||||||
{/each}
|
{#each upcomingEvents as event}
|
||||||
</div>
|
<EventCard
|
||||||
|
name={event.name}
|
||||||
|
slug={event.slug}
|
||||||
|
status={event.status}
|
||||||
|
startDate={event.start_date}
|
||||||
|
endDate={event.end_date}
|
||||||
|
color={event.color}
|
||||||
|
venueName={event.venue_name}
|
||||||
|
href="/{data.org.slug}/events/{event.slug}"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<SectionCard title={m.activity_title()}>
|
||||||
|
<ActivityFeed entries={recentActivity} />
|
||||||
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team Members Preview -->
|
<!-- Right Column: Quick Links + Team -->
|
||||||
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="flex items-center justify-between">
|
<SectionCard title={m.overview_quick_links()}>
|
||||||
<h2 class="text-h3 font-heading text-white">
|
<QuickLinkGrid links={quickLinks} />
|
||||||
{m.overview_stat_members()}
|
</SectionCard>
|
||||||
</h2>
|
|
||||||
<span class="text-body-sm text-light/40"
|
<SectionCard title={m.overview_stat_members()}>
|
||||||
>{stats.memberCount}</span
|
{#snippet titleRight()}
|
||||||
|
<span class="text-[12px] text-light/30">{stats.memberCount}</span>
|
||||||
|
{/snippet}
|
||||||
|
<MemberList
|
||||||
|
{members}
|
||||||
|
max={6}
|
||||||
|
moreHref="/{data.org.slug}/settings"
|
||||||
|
moreLabel={m.overview_more_members({ count: String(Math.max(0, stats.memberCount - 6)) })}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{#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"
|
||||||
>
|
>
|
||||||
</div>
|
<div class="w-10 h-10 rounded-xl bg-light/5 flex items-center justify-center">
|
||||||
<div class="flex flex-col gap-2">
|
<span
|
||||||
{#each members.slice(0, 5) as member}
|
class="material-symbols-rounded text-light/40 group-hover:text-white transition-colors"
|
||||||
<div class="flex items-center gap-3 px-1 py-1">
|
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||||
<Avatar
|
>settings</span
|
||||||
name={member.profiles?.full_name ||
|
>
|
||||||
member.profiles?.email ||
|
|
||||||
"?"}
|
|
||||||
src={member.profiles?.avatar_url}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-body-sm text-white truncate">
|
|
||||||
{member.profiles?.full_name ||
|
|
||||||
member.profiles?.email ||
|
|
||||||
"Unknown"}
|
|
||||||
</p>
|
|
||||||
<p class="text-[11px] text-light/40 capitalize">
|
|
||||||
{member.role}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<div>
|
||||||
{#if stats.memberCount > 5}
|
<p class="text-body-sm text-white group-hover:text-primary transition-colors">
|
||||||
<a
|
{m.nav_settings()}
|
||||||
href="/{data.org.slug}/settings"
|
</p>
|
||||||
class="text-body-sm text-primary hover:underline text-center pt-1"
|
<p class="text-[11px] text-light/30">{m.settings_general_title()}</p>
|
||||||
>
|
</div>
|
||||||
+{stats.memberCount - 5} more
|
</a>
|
||||||
</a>
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
36
src/routes/[orgSlug]/account/+layout.svelte
Normal file
36
src/routes/[orgSlug]/account/+layout.svelte
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isNavigatingHere = $derived(
|
||||||
|
$navigating?.to?.url.pathname.includes("/account") ?? false,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title={m.account_title()}
|
||||||
|
subtitle={m.account_subtitle()}
|
||||||
|
icon="person"
|
||||||
|
iconColor="text-light/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isNavigatingHere}
|
||||||
|
<ContentSkeleton variant="settings" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -227,16 +227,8 @@
|
|||||||
<title>Account Settings | Root</title>
|
<title>Account Settings | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
<div class="flex-1 p-6">
|
||||||
<!-- Header -->
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<div>
|
|
||||||
<h1 class="font-heading text-h1 text-white">{m.account_title()}</h1>
|
|
||||||
<p class="font-body text-body text-light/60 mt-1">
|
|
||||||
{m.account_subtitle()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1 min-h-0">
|
|
||||||
<!-- Profile Section -->
|
<!-- Profile Section -->
|
||||||
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
|
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
|
||||||
<h2 class="font-heading text-h3 text-white">
|
<h2 class="font-heading text-h3 text-white">
|
||||||
|
|||||||
31
src/routes/[orgSlug]/calendar/+layout.svelte
Normal file
31
src/routes/[orgSlug]/calendar/+layout.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isNavigatingHere = $derived(
|
||||||
|
$navigating?.to?.url.pathname.includes("/calendar") ?? false,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader title={m.nav_calendar()} icon="calendar_today" iconColor="text-blue-400" />
|
||||||
|
|
||||||
|
{#if isNavigatingHere}
|
||||||
|
<ContentSkeleton variant="calendar" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -456,13 +456,11 @@
|
|||||||
<title>Calendar - {data.org.name} | Root</title>
|
<title>Calendar - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Toolbar -->
|
||||||
<header class="flex items-center gap-2 p-1">
|
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
<div class="flex-1"></div>
|
||||||
{m.calendar_title()}
|
<Button size="sm" onclick={() => handleDateClick(new Date())}
|
||||||
</h1>
|
|
||||||
<Button size="md" onclick={() => handleDateClick(new Date())}
|
|
||||||
>{m.btn_new()}</Button
|
>{m.btn_new()}</Button
|
||||||
>
|
>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -502,10 +500,10 @@
|
|||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Calendar Grid -->
|
<!-- Calendar Grid -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto p-4">
|
||||||
<Calendar
|
<Calendar
|
||||||
events={allEvents}
|
events={allEvents}
|
||||||
onDateClick={handleDateClick}
|
onDateClick={handleDateClick}
|
||||||
|
|||||||
31
src/routes/[orgSlug]/documents/+layout.svelte
Normal file
31
src/routes/[orgSlug]/documents/+layout.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isNavigatingHere = $derived(
|
||||||
|
$navigating?.to?.url.pathname.includes("/documents") && !$navigating?.to?.url.pathname.includes("/events"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader title={m.nav_files()} icon="cloud" iconColor="text-emerald-400" />
|
||||||
|
|
||||||
|
{#if isNavigatingHere}
|
||||||
|
<ContentSkeleton variant="files" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<title>Files - {data.org.name} | Root</title>
|
<title>Files - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="h-full p-4 lg:p-5">
|
<div class="h-full p-6">
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
org={data.org}
|
org={data.org}
|
||||||
bind:documents
|
bind:documents
|
||||||
|
|||||||
49
src/routes/[orgSlug]/events/+layout.svelte
Normal file
49
src/routes/[orgSlug]/events/+layout.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating, page } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
userRole: string;
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
// Only show the events list header when on the events list page itself,
|
||||||
|
// not on event detail pages (which have their own layout)
|
||||||
|
const isEventsList = $derived(
|
||||||
|
$page.url.pathname === `/${data.org.slug}/events`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNavigatingToList = $derived(
|
||||||
|
$navigating?.to?.url.pathname === `/${data.org.slug}/events`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showListLayout = $derived(isEventsList || isNavigatingToList);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showListLayout}
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title={m.events_title()}
|
||||||
|
subtitle={m.events_subtitle()}
|
||||||
|
icon="celebration"
|
||||||
|
iconColor="text-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isNavigatingToList && !isEventsList}
|
||||||
|
<ContentSkeleton variant="list" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { EventCard, TabBar, Button } from "$lib/components/ui";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
@@ -68,45 +68,6 @@
|
|||||||
"#14B8A6",
|
"#14B8A6",
|
||||||
];
|
];
|
||||||
|
|
||||||
function getStatusColor(status: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
planning: "text-amber-400 bg-amber-400/10",
|
|
||||||
active: "text-emerald-400 bg-emerald-400/10",
|
|
||||||
completed: "text-blue-400 bg-blue-400/10",
|
|
||||||
archived: "text-light/40 bg-light/5",
|
|
||||||
};
|
|
||||||
return map[status] ?? "text-light/40 bg-light/5";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusIcon(status: string): string {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
planning: "edit_note",
|
|
||||||
active: "play_circle",
|
|
||||||
completed: "check_circle",
|
|
||||||
archived: "archive",
|
|
||||||
};
|
|
||||||
return map[status] ?? "help";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return "";
|
|
||||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateRange(
|
|
||||||
start: string | null,
|
|
||||||
end: string | null,
|
|
||||||
): string {
|
|
||||||
if (!start && !end) return m.events_no_dates();
|
|
||||||
if (start && !end) return formatDate(start);
|
|
||||||
if (!start && end) return `Until ${formatDate(end)}`;
|
|
||||||
return `${formatDate(start)} — ${formatDate(end)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!newEventName.trim()) return;
|
if (!newEventName.trim()) return;
|
||||||
creating = true;
|
creating = true;
|
||||||
@@ -171,166 +132,66 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Toolbar: Status Tabs + Create Button -->
|
||||||
<header
|
<div class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
class="flex items-center justify-between px-6 py-5 border-b border-light/5"
|
<div class="flex items-center gap-1">
|
||||||
>
|
{#each statusTabs as tab}
|
||||||
<div>
|
<button
|
||||||
<h1 class="text-h1 font-heading text-white">{m.events_title()}</h1>
|
type="button"
|
||||||
<p class="text-body-sm text-light/50 mt-1">
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
|
||||||
{m.events_subtitle()}
|
tab.value
|
||||||
</p>
|
? '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>
|
</div>
|
||||||
{#if isEditor}
|
{#if isEditor}
|
||||||
<button
|
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>
|
||||||
type="button"
|
|
||||||
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"
|
|
||||||
onclick={() => (showCreateModal = true)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="material-symbols-rounded"
|
|
||||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
||||||
>add</span
|
|
||||||
>
|
|
||||||
{m.events_new()}
|
{m.events_new()}
|
||||||
</button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Status Tabs -->
|
|
||||||
<div class="flex items-center gap-1 px-6 py-3 border-b border-light/5">
|
|
||||||
{#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/60 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>
|
</div>
|
||||||
|
|
||||||
<!-- Events Grid -->
|
<!-- Events Grid -->
|
||||||
<div class="flex-1 overflow-auto p-6">
|
<div class="flex-1 overflow-auto p-6">
|
||||||
{#if data.events.length === 0}
|
{#if data.events.length === 0}
|
||||||
<div
|
<div class="flex flex-col items-center justify-center h-full text-light/40">
|
||||||
class="flex flex-col items-center justify-center h-full text-light/40"
|
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded mb-4"
|
class="material-symbols-rounded mb-4"
|
||||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||||
>celebration</span
|
>celebration</span
|
||||||
>
|
>
|
||||||
<p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p>
|
<p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p>
|
||||||
<p class="text-body text-light/30">
|
<p class="text-body text-light/30">{m.events_empty_desc()}</p>
|
||||||
{m.events_empty_desc()}
|
|
||||||
</p>
|
|
||||||
{#if isEditor}
|
{#if isEditor}
|
||||||
<button
|
<div class="mt-4">
|
||||||
type="button"
|
<Button icon="add" onclick={() => (showCreateModal = true)}>
|
||||||
class="mt-4 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"
|
{m.events_create()}
|
||||||
onclick={() => (showCreateModal = true)}
|
</Button>
|
||||||
>
|
</div>
|
||||||
<span
|
|
||||||
class="material-symbols-rounded"
|
|
||||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
|
||||||
>add</span
|
|
||||||
>
|
|
||||||
{m.events_create()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{#each data.events as event}
|
{#each data.events as event}
|
||||||
<a
|
<EventCard
|
||||||
|
name={event.name}
|
||||||
|
slug={event.slug}
|
||||||
|
status={event.status}
|
||||||
|
startDate={event.start_date}
|
||||||
|
endDate={event.end_date}
|
||||||
|
color={event.color}
|
||||||
|
venueName={event.venue_name}
|
||||||
href="/{data.org.slug}/events/{event.slug}"
|
href="/{data.org.slug}/events/{event.slug}"
|
||||||
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-2xl p-5 flex flex-col gap-3 transition-all"
|
/>
|
||||||
>
|
|
||||||
<!-- Color bar + Status -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full"
|
|
||||||
style="background-color: {event.color ||
|
|
||||||
'#00A3E0'}"
|
|
||||||
></div>
|
|
||||||
<h3
|
|
||||||
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
|
|
||||||
>
|
|
||||||
{event.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="text-[11px] font-body px-2 py-0.5 rounded-full capitalize {getStatusColor(
|
|
||||||
event.status,
|
|
||||||
)}"
|
|
||||||
>
|
|
||||||
{event.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
{#if event.description}
|
|
||||||
<p
|
|
||||||
class="text-body-sm text-light/50 line-clamp-2"
|
|
||||||
>
|
|
||||||
{event.description}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Meta row -->
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-4 text-[12px] text-light/40 mt-auto pt-2"
|
|
||||||
>
|
|
||||||
<!-- Date -->
|
|
||||||
<div 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;"
|
|
||||||
>calendar_today</span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
>{formatDateRange(
|
|
||||||
event.start_date,
|
|
||||||
event.end_date,
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Venue -->
|
|
||||||
{#if event.venue_name}
|
|
||||||
<div 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;"
|
|
||||||
>location_on</span
|
|
||||||
>
|
|
||||||
<span class="truncate max-w-[120px]"
|
|
||||||
>{event.venue_name}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Members -->
|
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
|
||||||
<span
|
|
||||||
class="material-symbols-rounded"
|
|
||||||
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
|
||||||
>group</span
|
|
||||||
>
|
|
||||||
<span>{event.member_count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { Avatar } from "$lib/components/ui";
|
import { ModuleCard, SectionCard, StatusBadge } from "$lib/components/ui";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
@@ -394,36 +394,21 @@
|
|||||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||||
>
|
>
|
||||||
{#each moduleCards as mod}
|
{#each moduleCards as mod}
|
||||||
<a
|
<ModuleCard
|
||||||
|
label={mod.label}
|
||||||
|
description={mod.description}
|
||||||
|
icon={mod.icon}
|
||||||
href={mod.href}
|
href={mod.href}
|
||||||
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-xl p-4 flex flex-col gap-2 transition-all"
|
color={mod.color}
|
||||||
>
|
bg={mod.bg}
|
||||||
<div
|
/>
|
||||||
class="w-10 h-10 rounded-xl {mod.bg} flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="material-symbols-rounded {mod.color}"
|
|
||||||
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
|
||||||
>{mod.icon}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<h3
|
|
||||||
class="text-body font-heading text-white group-hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{mod.label}
|
|
||||||
</h3>
|
|
||||||
<p class="text-[12px] text-light/40">{mod.description}</p>
|
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Event Details Section -->
|
<!-- Event Details Section -->
|
||||||
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Info Card -->
|
<!-- Info Card -->
|
||||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
<SectionCard title={m.events_details()}>
|
||||||
<h3 class="text-body font-heading text-white mb-3">
|
|
||||||
{m.events_details()}
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
@@ -474,41 +459,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SectionCard>
|
||||||
|
|
||||||
<!-- Team Card -->
|
<!-- Team Card -->
|
||||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
<SectionCard title={m.events_team_count({ count: String(data.eventMembers.length) })}>
|
||||||
<div class="flex items-center justify-between mb-3">
|
{#snippet titleRight()}
|
||||||
<h3 class="text-body font-heading text-white">
|
|
||||||
{m.events_team_count({ count: String(data.eventMembers.length) })}
|
|
||||||
</h3>
|
|
||||||
<a
|
<a
|
||||||
href="{basePath}/team"
|
href="{basePath}/team"
|
||||||
class="text-[12px] text-primary hover:underline"
|
class="text-[12px] text-primary hover:underline"
|
||||||
>{m.events_team_manage()}</a
|
>{m.events_team_manage()}</a
|
||||||
>
|
>
|
||||||
</div>
|
{/snippet}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each data.eventMembers.slice(0, 6) as member}
|
{#each data.eventMembers.slice(0, 6) as member}
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<Avatar
|
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-[11px] font-bold text-primary shrink-0">
|
||||||
name={member.profile?.full_name ||
|
{(member.profile?.full_name || member.profile?.email || "?").charAt(0).toUpperCase()}
|
||||||
member.profile?.email ||
|
</div>
|
||||||
"?"}
|
|
||||||
src={member.profile?.avatar_url}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p
|
<p class="text-body-sm text-white truncate">
|
||||||
class="text-body-sm text-white truncate"
|
|
||||||
>
|
|
||||||
{member.profile?.full_name ||
|
{member.profile?.full_name ||
|
||||||
member.profile?.email ||
|
member.profile?.email ||
|
||||||
"Unknown"}
|
"Unknown"}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p class="text-[11px] text-light/40 capitalize">
|
||||||
class="text-[11px] text-light/40 capitalize"
|
|
||||||
>
|
|
||||||
{member.role}
|
{member.role}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,7 +502,7 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
31
src/routes/[orgSlug]/kanban/+layout.svelte
Normal file
31
src/routes/[orgSlug]/kanban/+layout.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isNavigatingHere = $derived(
|
||||||
|
$navigating?.to?.url.pathname.includes("/kanban") ?? false,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader title={m.kanban_title()} icon="view_kanban" iconColor="text-purple-400" />
|
||||||
|
|
||||||
|
{#if isNavigatingHere}
|
||||||
|
<ContentSkeleton variant="kanban" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -494,13 +494,13 @@
|
|||||||
>
|
>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Board toolbar -->
|
||||||
<header class="flex items-center gap-2 p-1">
|
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
{#if isRenamingBoard && selectedBoard}
|
{#if isRenamingBoard && selectedBoard}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-h1 focus:outline-none"
|
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-body focus:outline-none"
|
||||||
bind:value={renameBoardValue}
|
bind:value={renameBoardValue}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Enter") confirmBoardRename();
|
if (e.key === "Enter") confirmBoardRename();
|
||||||
@@ -509,12 +509,30 @@
|
|||||||
onblur={confirmBoardRename}
|
onblur={confirmBoardRename}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else if selectedBoard}
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
<h2 class="font-heading text-body text-white">{selectedBoard.name}</h2>
|
||||||
{selectedBoard ? selectedBoard.name : m.kanban_title()}
|
|
||||||
</h1>
|
|
||||||
{/if}
|
{/if}
|
||||||
<Button size="md" onclick={() => (showCreateBoardModal = true)}
|
|
||||||
|
{#if boards.length > 1}
|
||||||
|
<div class="flex gap-1 ml-2">
|
||||||
|
{#each boards as board}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1 rounded-lg text-[12px] font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
|
||||||
|
board.id
|
||||||
|
? 'bg-primary text-background'
|
||||||
|
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||||
|
onclick={() => loadBoard(board.id)}
|
||||||
|
>
|
||||||
|
{board.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<Button size="sm" onclick={() => (showCreateBoardModal = true)}
|
||||||
>{m.btn_new()}</Button
|
>{m.btn_new()}</Button
|
||||||
>
|
>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -539,28 +557,10 @@
|
|||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Board selector (compact) -->
|
|
||||||
{#if boards.length > 1}
|
|
||||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
|
||||||
{#each boards as board}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="px-4 py-2 rounded-[32px] text-sm font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
|
|
||||||
board.id
|
|
||||||
? 'bg-primary text-night'
|
|
||||||
: 'bg-dark text-light hover:bg-dark/80'}"
|
|
||||||
onclick={() => loadBoard(board.id)}
|
|
||||||
>
|
|
||||||
{board.name}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Kanban Board -->
|
<!-- Kanban Board -->
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden p-4">
|
||||||
{#if selectedBoard}
|
{#if selectedBoard}
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
columns={selectedBoard.columns}
|
columns={selectedBoard.columns}
|
||||||
|
|||||||
35
src/routes/[orgSlug]/settings/+layout.svelte
Normal file
35
src/routes/[orgSlug]/settings/+layout.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isNavigatingHere = $derived(
|
||||||
|
$navigating?.to?.url.pathname.includes("/settings") ?? false,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title={m.settings_title()}
|
||||||
|
icon="settings"
|
||||||
|
iconColor="text-light/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isNavigatingHere}
|
||||||
|
<ContentSkeleton variant="settings" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -274,33 +274,24 @@
|
|||||||
<title>Settings - {data.org.name} | Root</title>
|
<title>Settings - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Tab Navigation -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-wrap gap-1 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
|
{#each tabs as tab}
|
||||||
<Avatar name="Settings" size="md" />
|
<button
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
type="button"
|
||||||
{m.settings_title()}
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {activeTab === tab.id
|
||||||
</h1>
|
? 'bg-primary text-background'
|
||||||
<IconButton title="More options">
|
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||||
<Icon name="more_horiz" size={24} />
|
onclick={() => (activeTab = tab.id)}
|
||||||
</IconButton>
|
>
|
||||||
</header>
|
{tab.label}
|
||||||
|
</button>
|
||||||
<!-- Pill Tab Navigation -->
|
{/each}
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
{#each tabs as tab}
|
|
||||||
<Button
|
|
||||||
variant={activeTab === tab.id ? "primary" : "secondary"}
|
|
||||||
size="md"
|
|
||||||
onclick={() => (activeTab = tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
{#if activeTab === "general"}
|
{#if activeTab === "general"}
|
||||||
<SettingsGeneral
|
<SettingsGeneral
|
||||||
@@ -416,6 +407,7 @@
|
|||||||
serviceAccountEmail={data.serviceAccountEmail ?? null}
|
serviceAccountEmail={data.serviceAccountEmail ?? null}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Tag Modal -->
|
<!-- Create/Edit Tag Modal -->
|
||||||
|
|||||||
Reference in New Issue
Block a user