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:
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 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';
|
||||
|
||||
Reference in New Issue
Block a user