From 2913912cb856e1f5e200eedf03ae6af778a84aee Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Sat, 7 Feb 2026 10:44:53 +0200 Subject: [PATCH] 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 --- messages/en.json | 8 +- messages/et.json | 8 +- src/lib/components/ui/ActivityFeed.svelte | 132 ++++++ src/lib/components/ui/ContentSkeleton.svelte | 109 +++++ src/lib/components/ui/EventCard.svelte | 116 +++++ src/lib/components/ui/MemberList.svelte | 71 +++ src/lib/components/ui/ModuleCard.svelte | 40 ++ src/lib/components/ui/PageHeader.svelte | 46 ++ src/lib/components/ui/QuickLinkGrid.svelte | 30 ++ src/lib/components/ui/SectionCard.svelte | 41 ++ src/lib/components/ui/StatCard.svelte | 58 +++ src/lib/components/ui/StatusBadge.svelte | 30 ++ src/lib/components/ui/TabBar.svelte | 37 ++ src/lib/components/ui/index.ts | 11 + src/routes/[orgSlug]/+layout.server.ts | 19 +- src/routes/[orgSlug]/+layout.svelte | 24 +- src/routes/[orgSlug]/+page.svelte | 440 +++++++----------- src/routes/[orgSlug]/account/+layout.svelte | 36 ++ src/routes/[orgSlug]/account/+page.svelte | 12 +- src/routes/[orgSlug]/calendar/+layout.svelte | 31 ++ src/routes/[orgSlug]/calendar/+page.svelte | 16 +- src/routes/[orgSlug]/documents/+layout.svelte | 31 ++ src/routes/[orgSlug]/documents/+page.svelte | 2 +- src/routes/[orgSlug]/events/+layout.svelte | 49 ++ src/routes/[orgSlug]/events/+page.svelte | 217 ++------- .../[orgSlug]/events/[eventSlug]/+page.svelte | 64 +-- src/routes/[orgSlug]/kanban/+layout.svelte | 31 ++ src/routes/[orgSlug]/kanban/+page.svelte | 58 +-- src/routes/[orgSlug]/settings/+layout.svelte | 35 ++ src/routes/[orgSlug]/settings/+page.svelte | 42 +- 30 files changed, 1240 insertions(+), 604 deletions(-) create mode 100644 src/lib/components/ui/ActivityFeed.svelte create mode 100644 src/lib/components/ui/ContentSkeleton.svelte create mode 100644 src/lib/components/ui/EventCard.svelte create mode 100644 src/lib/components/ui/MemberList.svelte create mode 100644 src/lib/components/ui/ModuleCard.svelte create mode 100644 src/lib/components/ui/PageHeader.svelte create mode 100644 src/lib/components/ui/QuickLinkGrid.svelte create mode 100644 src/lib/components/ui/SectionCard.svelte create mode 100644 src/lib/components/ui/StatCard.svelte create mode 100644 src/lib/components/ui/StatusBadge.svelte create mode 100644 src/lib/components/ui/TabBar.svelte create mode 100644 src/routes/[orgSlug]/account/+layout.svelte create mode 100644 src/routes/[orgSlug]/calendar/+layout.svelte create mode 100644 src/routes/[orgSlug]/documents/+layout.svelte create mode 100644 src/routes/[orgSlug]/events/+layout.svelte create mode 100644 src/routes/[orgSlug]/kanban/+layout.svelte create mode 100644 src/routes/[orgSlug]/settings/+layout.svelte diff --git a/messages/en.json b/messages/en.json index 2f9bed3..23d83fd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -320,5 +320,11 @@ "events_mod_team": "Team", "events_mod_team_desc": "Team members and shift scheduling", "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" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index b3be0d3..228d7cc 100644 --- a/messages/et.json +++ b/messages/et.json @@ -320,5 +320,11 @@ "events_mod_team": "Meeskond", "events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine", "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" } \ No newline at end of file diff --git a/src/lib/components/ui/ActivityFeed.svelte b/src/lib/components/ui/ActivityFeed.svelte new file mode 100644 index 0000000..1f2db1d --- /dev/null +++ b/src/lib/components/ui/ActivityFeed.svelte @@ -0,0 +1,132 @@ + + +{#if entries.length === 0} +
+ history +

{emptyLabel ?? m.activity_empty()}

+
+{:else} +
+ {#each entries as entry} +
+ {getActivityIcon(entry.action)} +
+

+ {getDescription(entry)} +

+
+ {formatTimeAgo(entry.created_at)} +
+ {/each} +
+{/if} diff --git a/src/lib/components/ui/ContentSkeleton.svelte b/src/lib/components/ui/ContentSkeleton.svelte new file mode 100644 index 0000000..e6e59da --- /dev/null +++ b/src/lib/components/ui/ContentSkeleton.svelte @@ -0,0 +1,109 @@ + + +
+ {#if variant === "kanban"} +
+ {#each Array(3) as _} +
+
+ + +
+ {#each Array(3) as __} + + {/each} +
+ {/each} +
+ {:else if variant === "files"} +
+ +
+ + +
+
+ {#each Array(12) as _} + + {/each} +
+ {:else if variant === "calendar"} +
+ + + +
+ +
+
+ {#each Array(7) as _} + + {/each} + {#each Array(35) as _} + + {/each} +
+ {:else if variant === "settings"} +
+ + + + +
+ {:else if variant === "list"} +
+ {#each Array(4) as _} + + {/each} +
+
+ {#each Array(5) as _} + + {/each} +
+ {:else if variant === "detail"} +
+
+ + +
+
+ + +
+
+ {:else} +
+ {#each Array(4) as _} + + {/each} +
+
+
+ +
+
+ + +
+
+ {/if} +
+ + diff --git a/src/lib/components/ui/EventCard.svelte b/src/lib/components/ui/EventCard.svelte new file mode 100644 index 0000000..4285283 --- /dev/null +++ b/src/lib/components/ui/EventCard.svelte @@ -0,0 +1,116 @@ + + +{#if compact} + + +
+
+

+ {name} +

+
+ {#if startDate} + {formatDate(startDate)}{endDate + ? ` — ${formatDate(endDate)}` + : ""} + {/if} + {#if venueName} + · {venueName} + {/if} +
+
+ +
+{:else} + + +
+
+
+

+ {name} +

+
+ +
+ +
+ {#if startDate} + + calendar_today + {formatDate(startDate)}{endDate + ? ` — ${formatDate(endDate)}` + : ""} + + {/if} + {#if venueName} + + location_on + {venueName} + + {/if} +
+
+{/if} diff --git a/src/lib/components/ui/MemberList.svelte b/src/lib/components/ui/MemberList.svelte new file mode 100644 index 0000000..97878a0 --- /dev/null +++ b/src/lib/components/ui/MemberList.svelte @@ -0,0 +1,71 @@ + + +
+ {#each visible as member} +
+ +
+

+ {member.profiles?.full_name || + member.profiles?.email || + "Unknown"} +

+

+ {member.role} +

+
+
+ {/each} + {#if remaining > 0 && moreHref && moreLabel} + + {moreLabel} + + {/if} + {#if members.length === 0 && emptyLabel} +

+ {emptyLabel} +

+ {/if} +
diff --git a/src/lib/components/ui/ModuleCard.svelte b/src/lib/components/ui/ModuleCard.svelte new file mode 100644 index 0000000..daec6e7 --- /dev/null +++ b/src/lib/components/ui/ModuleCard.svelte @@ -0,0 +1,40 @@ + + + +
+ {icon} +
+

+ {label} +

+

{description}

+
diff --git a/src/lib/components/ui/PageHeader.svelte b/src/lib/components/ui/PageHeader.svelte new file mode 100644 index 0000000..034b157 --- /dev/null +++ b/src/lib/components/ui/PageHeader.svelte @@ -0,0 +1,46 @@ + + +
+
+ {#if icon} + {icon} + {/if} +
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
diff --git a/src/lib/components/ui/QuickLinkGrid.svelte b/src/lib/components/ui/QuickLinkGrid.svelte new file mode 100644 index 0000000..40707e6 --- /dev/null +++ b/src/lib/components/ui/QuickLinkGrid.svelte @@ -0,0 +1,30 @@ + + +
+ {#each links as link} + + {link.icon} + {link.label} + + {/each} +
diff --git a/src/lib/components/ui/SectionCard.svelte b/src/lib/components/ui/SectionCard.svelte new file mode 100644 index 0000000..bdb4e27 --- /dev/null +++ b/src/lib/components/ui/SectionCard.svelte @@ -0,0 +1,41 @@ + + +
+ {#if title || titleRight} +
+ {#if title} +

{title}

+ {/if} + {#if titleRight} + {@render titleRight()} + {/if} +
+ {/if} + {@render children()} +
diff --git a/src/lib/components/ui/StatCard.svelte b/src/lib/components/ui/StatCard.svelte new file mode 100644 index 0000000..54f06ea --- /dev/null +++ b/src/lib/components/ui/StatCard.svelte @@ -0,0 +1,58 @@ + + +{#if href} + +
+ {icon} +
+
+

{value}

+

{label}

+
+
+{:else} +
+
+ {icon} +
+
+

{value}

+

{label}

+
+
+{/if} diff --git a/src/lib/components/ui/StatusBadge.svelte b/src/lib/components/ui/StatusBadge.svelte new file mode 100644 index 0000000..7e45307 --- /dev/null +++ b/src/lib/components/ui/StatusBadge.svelte @@ -0,0 +1,30 @@ + + +{status} diff --git a/src/lib/components/ui/TabBar.svelte b/src/lib/components/ui/TabBar.svelte new file mode 100644 index 0000000..fa033cf --- /dev/null +++ b/src/lib/components/ui/TabBar.svelte @@ -0,0 +1,37 @@ + + +
+ {#each tabs as tab} + + {/each} +
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts index eb32b1c..305a794 100644 --- a/src/lib/components/ui/index.ts +++ b/src/lib/components/ui/index.ts @@ -26,6 +26,17 @@ export { default as Icon } from './Icon.svelte'; export { default as AssigneePicker } from './AssigneePicker.svelte'; export { default as ContextMenu } from './ContextMenu.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 Twemoji } from './Twemoji.svelte'; export { default as EmojiPicker } from './EmojiPicker.svelte'; diff --git a/src/routes/[orgSlug]/+layout.server.ts b/src/routes/[orgSlug]/+layout.server.ts index e2345fa..153acd2 100644 --- a/src/routes/[orgSlug]/+layout.server.ts +++ b/src/routes/[orgSlug]/+layout.server.ts @@ -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) - 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 .from('org_members') .select('role, role_id') @@ -68,7 +68,11 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { .from('documents') .select('id', { count: 'exact', head: true }) .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; @@ -81,6 +85,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { documentCount: docCountResult.count ?? 0, folderCount: folderCountResult.count ?? 0, kanbanCount: kanbanCountResult.count ?? 0, + eventCount: eventCountResult.count ?? 0, }; if (!membership) { @@ -121,6 +126,15 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { 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 { org, userRole: membership.role, @@ -128,6 +142,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => { members, recentActivity: recentActivity ?? [], stats, + upcomingEvents: upcomingEvents ?? [], user, profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null } }; diff --git a/src/routes/[orgSlug]/+layout.svelte b/src/routes/[orgSlug]/+layout.svelte index 9cffd9e..6da719f 100644 --- a/src/routes/[orgSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/+layout.svelte @@ -1,10 +1,10 @@ {data.org.name} | Root -
- -
-

{data.org.name}

-

{m.overview_title()}

-
- - -
- {#each statCards as stat} - {#if stat.href} +
+ + {#snippet actions()} + {#if isEditor} -
- - {stat.icon} - -
-
-

- {stat.value} -

-

{stat.label}

-
-
- {:else} -
-
- - {stat.icon} - -
-
-

- {stat.value} -

-

{stat.label}

-
-
- {/if} - {/each} -
- -
- -
-

- {m.activity_title()} -

- - {#if recentActivity.length === 0} -
celebration - history - -

{m.activity_empty()}

-
- {:else} -
- {#each recentActivity as entry} -
- - {getActivityIcon(entry.action)} - -
-

- {getActivityDescription(entry)} -

-

- {formatTimeAgo(entry.created_at)} -

-
-
- {/each} -
+ {m.nav_events()} + {/if} + {/snippet} + + +
+ +
+ + + +
- -
- -
-

- {m.overview_quick_links()} -

-
- {#each quickLinks as link} +
+ +
+ + + {#snippet titleRight()} {m.overview_view_all_events()} + {/snippet} + + {#if upcomingEvents.length === 0} +
celebration - {link.icon} - - {link.label} - - {/each} -
+

{m.overview_upcoming_empty()}

+
+ {:else} +
+ {#each upcomingEvents as event} + + {/each} +
+ {/if} + + + + + +
- -
-
-

- {m.overview_stat_members()} -

- {stats.memberCount} +
+ + + + + + {#snippet titleRight()} + {stats.memberCount} + {/snippet} + + + + {#if isAdmin} + -
-
diff --git a/src/routes/[orgSlug]/account/+layout.svelte b/src/routes/[orgSlug]/account/+layout.svelte new file mode 100644 index 0000000..ed00bc3 --- /dev/null +++ b/src/routes/[orgSlug]/account/+layout.svelte @@ -0,0 +1,36 @@ + + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/account/+page.svelte b/src/routes/[orgSlug]/account/+page.svelte index d05b6ce..9aba7c9 100644 --- a/src/routes/[orgSlug]/account/+page.svelte +++ b/src/routes/[orgSlug]/account/+page.svelte @@ -227,16 +227,8 @@ Account Settings | Root -
- -
-

{m.account_title()}

-

- {m.account_subtitle()} -

-
- -
+
+

diff --git a/src/routes/[orgSlug]/calendar/+layout.svelte b/src/routes/[orgSlug]/calendar/+layout.svelte new file mode 100644 index 0000000..76cd2eb --- /dev/null +++ b/src/routes/[orgSlug]/calendar/+layout.svelte @@ -0,0 +1,31 @@ + + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index bda44a0..a33f42b 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -456,13 +456,11 @@ Calendar - {data.org.name} | Root -
- -
-

- {m.calendar_title()} -

- -
+
-
+
+ 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"), + ); + + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/documents/+page.svelte b/src/routes/[orgSlug]/documents/+page.svelte index 892ad33..3091165 100644 --- a/src/routes/[orgSlug]/documents/+page.svelte +++ b/src/routes/[orgSlug]/documents/+page.svelte @@ -22,7 +22,7 @@ Files - {data.org.name} | Root -
+
+ 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); + + +{#if showListLayout} +
+ + + {#if isNavigatingToList && !isEventsList} + + {:else} +
+ {@render children()} +
+ {/if} +
+{:else} + {@render children()} +{/if} diff --git a/src/routes/[orgSlug]/events/+page.svelte b/src/routes/[orgSlug]/events/+page.svelte index 16bd286..3d60936 100644 --- a/src/routes/[orgSlug]/events/+page.svelte +++ b/src/routes/[orgSlug]/events/+page.svelte @@ -1,7 +1,7 @@ + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/kanban/+page.svelte b/src/routes/[orgSlug]/kanban/+page.svelte index 866b5ef..f2774cc 100644 --- a/src/routes/[orgSlug]/kanban/+page.svelte +++ b/src/routes/[orgSlug]/kanban/+page.svelte @@ -494,13 +494,13 @@ > -
- -
+
+ +
{#if isRenamingBoard && selectedBoard} { if (e.key === "Enter") confirmBoardRename(); @@ -509,12 +509,30 @@ onblur={confirmBoardRename} autofocus /> - {:else} -

- {selectedBoard ? selectedBoard.name : m.kanban_title()} -

+ {:else if selectedBoard} +

{selectedBoard.name}

{/if} - + {/each} +
+ {/if} + +
+ + -
- - - {#if boards.length > 1} -
- {#each boards as board} - - {/each} -
- {/if} +
-
+
{#if selectedBoard} + 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, + ); + + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/settings/+page.svelte b/src/routes/[orgSlug]/settings/+page.svelte index 5a83189..52158aa 100644 --- a/src/routes/[orgSlug]/settings/+page.svelte +++ b/src/routes/[orgSlug]/settings/+page.svelte @@ -274,33 +274,24 @@ Settings - {data.org.name} | Root -
- -
-
- -

- {m.settings_title()} -

- - - -
- - -
- {#each tabs as tab} - - {/each} -
+
+ +
+ {#each tabs as tab} + + {/each}
+
+ {#if activeTab === "general"} {/if} +