Redesign dashboard UI with mobile responsive layout

- Add Geist font family and refined OKLCH color palette
- Redesign all dashboard components with polished styling
- Implement URL-synced tabs for performance table (domain, country, source, ad-unit, key-value)
- Add mobile hamburger menu using shadcn Sheet component
- Make sidebar responsive (hidden on mobile, visible on lg+)
- Add custom shadow utilities and improved visual hierarchy
- Add sparklines with gradients to metric cards
- Redesign peak traffic chart as heatmap grid
- Add icons and hover states to frequent filters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-07 13:59:25 +08:00
parent ccd8f5c3db
commit f8d543565c
30 changed files with 1536 additions and 397 deletions

View File

@@ -19,7 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.10.1", "@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0", "@lucide/svelte": "^0.561.0",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1", "@sveltejs/kit": "^2.21.1",

View File

@@ -1,83 +1,170 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
/* Geist Sans - Modern, clean typeface */
@font-face {
font-family: "Geist";
src: url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/Geist-Regular.woff2")
format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist";
src: url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/Geist-Medium.woff2")
format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist";
src: url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/Geist-SemiBold.woff2")
format("woff2");
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist";
src: url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-sans/Geist-Bold.woff2")
format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/GeistMono-Regular.woff2")
format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Geist Mono";
src: url("https://cdn.jsdelivr.net/npm/geist@1.3.1/dist/fonts/geist-mono/GeistMono-Medium.woff2")
format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--radius: 0.625rem; --radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); /* Refined zinc palette with subtle warmth */
--background: oklch(0.985 0.002 280);
--foreground: oklch(0.145 0.005 285);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.145 0.005 285);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823); --popover-foreground: oklch(0.145 0.005 285);
--primary: oklch(0.21 0.006 285.885);
--primary: oklch(0.205 0.006 285);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary: oklch(0.96 0.002 280);
--muted: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.205 0.006 285);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375); --muted: oklch(0.96 0.002 280);
--accent-foreground: oklch(0.21 0.006 285.885); --muted-foreground: oklch(0.48 0.012 280);
--accent: oklch(0.96 0.002 280);
--accent-foreground: oklch(0.205 0.006 285);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32); --border: oklch(0.91 0.003 280);
--ring: oklch(0.705 0.015 286.067); --input: oklch(0.91 0.003 280);
--chart-1: oklch(0.646 0.222 41.116); --ring: oklch(0.65 0.015 280);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); /* Accent colors for data viz */
--chart-4: oklch(0.828 0.189 84.429); --success: oklch(0.65 0.19 145);
--chart-5: oklch(0.769 0.188 70.08); --warning: oklch(0.75 0.18 65);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823); --chart-1: oklch(0.55 0.2 250);
--sidebar-primary: oklch(0.21 0.006 285.885); --chart-2: oklch(0.6 0.15 175);
--chart-3: oklch(0.5 0.12 220);
--chart-4: oklch(0.7 0.16 85);
--chart-5: oklch(0.65 0.18 35);
--sidebar: oklch(0.98 0.001 280);
--sidebar-foreground: oklch(0.145 0.005 285);
--sidebar-primary: oklch(0.205 0.006 285);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent: oklch(0.94 0.002 280);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.205 0.006 285);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.91 0.003 280);
--sidebar-ring: oklch(0.705 0.015 286.067); --sidebar-ring: oklch(0.65 0.015 280);
} }
.dark { .dark {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.12 0.005 280);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.96 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0); --card: oklch(0.16 0.005 280);
--popover: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.96 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32); --popover: oklch(0.16 0.005 280);
--primary-foreground: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.96 0 0);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --primary: oklch(0.92 0.003 280);
--muted: oklch(0.274 0.006 286.033); --primary-foreground: oklch(0.12 0.005 280);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033); --secondary: oklch(0.22 0.005 280);
--accent-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.96 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --muted: oklch(0.22 0.005 280);
--input: oklch(1 0 0 / 15%); --muted-foreground: oklch(0.6 0.01 280);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376); --accent: oklch(0.22 0.005 280);
--chart-2: oklch(0.696 0.17 162.48); --accent-foreground: oklch(0.96 0 0);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --destructive: oklch(0.65 0.2 25);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885); --border: oklch(0.26 0.005 280);
--sidebar-foreground: oklch(0.985 0 0); --input: oklch(0.26 0.005 280);
--sidebar-primary: oklch(0.488 0.243 264.376); --ring: oklch(0.5 0.01 280);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033); --success: oklch(0.7 0.2 145);
--sidebar-accent-foreground: oklch(0.985 0 0); --warning: oklch(0.8 0.18 65);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938); --chart-1: oklch(0.6 0.22 250);
--chart-2: oklch(0.65 0.17 175);
--chart-3: oklch(0.7 0.18 85);
--chart-4: oklch(0.6 0.2 310);
--chart-5: oklch(0.65 0.22 25);
--sidebar: oklch(0.14 0.005 280);
--sidebar-foreground: oklch(0.96 0 0);
--sidebar-primary: oklch(0.6 0.22 250);
--sidebar-primary-foreground: oklch(0.96 0 0);
--sidebar-accent: oklch(0.22 0.005 280);
--sidebar-accent-foreground: oklch(0.96 0 0);
--sidebar-border: oklch(0.26 0.005 280);
--sidebar-ring: oklch(0.5 0.01 280);
} }
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--radius-md: calc(var(--radius) - 2px); --font-mono: "Geist Mono", ui-monospace, monospace;
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 6px);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
@@ -96,6 +183,8 @@
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-success: var(--success);
--color-warning: var(--warning);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
@@ -113,13 +202,57 @@
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border;
} }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body { body {
@apply bg-background text-foreground; @apply bg-background font-sans text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
} }
button, button,
[role="button"] { [role="button"] {
@apply cursor-pointer; @apply cursor-pointer;
} }
/* Refined focus styles */
:focus-visible {
@apply outline-none ring-2 ring-ring/40 ring-offset-2 ring-offset-background;
}
/* Smooth scrolling */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
}
/* Utility classes for consistent shadows */
@layer utilities {
.shadow-card {
box-shadow:
0 1px 2px 0 oklch(0 0 0 / 0.03),
0 1px 3px 0 oklch(0 0 0 / 0.04);
}
.shadow-card-hover {
box-shadow:
0 2px 4px 0 oklch(0 0 0 / 0.04),
0 3px 6px 0 oklch(0 0 0 / 0.05);
}
.shadow-elevated {
box-shadow:
0 4px 6px -1px oklch(0 0 0 / 0.05),
0 2px 4px -2px oklch(0 0 0 / 0.05);
}
} }

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -9,24 +8,59 @@ interface Props {
let { class: className }: Props = $props(); let { class: className }: Props = $props();
const filters = [ const filters = [
{ label: "For 9 countries, on weekends", href: "#" }, { label: "9 countries, weekends", icon: "globe", href: "#" },
{ label: "North America region", href: "#" }, { label: "North America region", icon: "map", href: "#" },
{ label: "Mobile devices only", icon: "mobile", href: "#" },
]; ];
</script> </script>
<Card.Root class={cn("", className)}> <div
<Card.Header class="pb-3"> class={cn(
<Card.Title class="text-sm font-medium text-muted-foreground">Frequent filters</Card.Title> "overflow-hidden rounded-lg border border-border bg-card shadow-card",
</Card.Header> className,
)}
>
<div class="border-b border-border px-5 py-3">
<h3 class="text-sm font-semibold text-foreground">Saved Filters</h3>
</div>
<Card.Content class="space-y-2"> <div class="divide-y divide-border/50">
{#each filters as filter} {#each filters as filter}
<a <a
href={filter.href} href={filter.href}
class="block text-sm font-medium text-foreground underline underline-offset-4 transition-colors hover:text-primary" class="group flex items-center gap-3 px-5 py-3 transition-colors hover:bg-muted/30"
> >
{filter.label} <div class="flex h-7 w-7 items-center justify-center rounded-md bg-muted text-muted-foreground transition-colors group-hover:bg-foreground/10 group-hover:text-foreground">
{#if filter.icon === "globe"}
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if filter.icon === "map"}
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4zM8 2v16M16 6v16" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if filter.icon === "mobile"}
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<path d="M12 18h.01" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</div>
<span class="text-[13px] font-medium text-foreground group-hover:text-foreground">{filter.label}</span>
<svg class="ml-auto h-4 w-4 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="m9 18 6-6-6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a> </a>
{/each} {/each}
</Card.Content> </div>
</Card.Root>
<div class="border-t border-border px-5 py-3">
<button class="flex w-full items-center justify-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" stroke-linecap="round" />
</svg>
Create new filter
</button>
</div>
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -19,19 +18,17 @@ let {
}: Props = $props(); }: Props = $props();
const isPositive = $derived(change >= 0); const isPositive = $derived(change >= 0);
const changeColor = $derived(isPositive ? "text-emerald-600" : "text-red-500");
const sparklineColor = $derived(isPositive ? "#10b981" : "#ef4444");
// Generate SVG path from sparkline data
function generateSparklinePath(data: number[]): string { function generateSparklinePath(data: number[]): string {
if (data.length < 2) { if (data.length < 2) {
return ""; return "";
} }
const min = Math.min(...data); const min = Math.min(...data);
const max = Math.max(...data); const max = Math.max(...data);
const range = max - min || 1; const range = max - min || 1;
const width = 80; const width = 64;
const height = 32; const height = 28;
const padding = 2; const padding = 2;
const points = data.map((val, i) => { const points = data.map((val, i) => {
@@ -43,36 +40,102 @@ function generateSparklinePath(data: number[]): string {
return `M${points.join(" L")}`; return `M${points.join(" L")}`;
} }
function generateAreaPath(data: number[]): string {
if (data.length < 2) {
return "";
}
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const width = 64;
const height = 28;
const padding = 2;
const points = data.map((val, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - padding - ((val - min) / range) * (height - padding * 2);
return `${x},${y}`;
});
return `M0,${height} L${points.join(" L")} L${width},${height} Z`;
}
const sparklinePath = $derived(generateSparklinePath(sparklineData)); const sparklinePath = $derived(generateSparklinePath(sparklineData));
const areaPath = $derived(generateAreaPath(sparklineData));
</script> </script>
<Card.Root class={cn("relative overflow-hidden", className)}> <div
<Card.Content class="p-4"> class={cn(
"group relative overflow-hidden rounded-lg border border-border bg-card p-5 shadow-card transition-all duration-200 hover:shadow-card-hover",
className,
)}
>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="space-y-1"> <div class="space-y-2">
<p class="text-sm text-muted-foreground">{label}</p> <p class="text-[13px] font-medium text-muted-foreground">{label}</p>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-semibold tracking-tight">{value}</span> <div class="flex items-baseline gap-2.5">
<span class={cn("text-sm font-medium", changeColor)}> <span class="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
{isPositive ? "+" : ""}{change.toFixed(2)}% <span
class={cn(
"inline-flex items-center gap-0.5 text-xs font-medium",
isPositive ? "text-success" : "text-destructive",
)}
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
{#if isPositive}
<path d="M18 15l-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
{:else}
<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
{/if}
</svg>
{Math.abs(change).toFixed(2)}%
</span> </span>
</div> </div>
</div> </div>
{#if sparklineData.length > 0} {#if sparklineData.length > 0}
<div class="flex-shrink-0"> <div class="mt-1 flex-shrink-0 opacity-80 transition-opacity group-hover:opacity-100">
<svg width="80" height="32" class="overflow-visible"> <svg width="64" height="28" class="overflow-visible">
<!-- Gradient definition -->
<defs>
<linearGradient id="sparkGradient-{label}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop
offset="0%"
stop-color={isPositive ? "oklch(0.65 0.19 145)" : "oklch(0.577 0.245 27.325)"}
stop-opacity="0.15"
/>
<stop
offset="100%"
stop-color={isPositive ? "oklch(0.65 0.19 145)" : "oklch(0.577 0.245 27.325)"}
stop-opacity="0"
/>
</linearGradient>
</defs>
<!-- Area fill -->
<path d={areaPath} fill="url(#sparkGradient-{label})" />
<!-- Line -->
<path <path
d={sparklinePath} d={sparklinePath}
fill="none" fill="none"
stroke={sparklineColor} stroke={isPositive ? "oklch(0.65 0.19 145)" : "oklch(0.577 0.245 27.325)"}
stroke-width="2" stroke-width="1.5"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
/> />
<!-- End dot -->
<circle
cx="64"
cy={sparklinePath.split(" ").pop()?.split(",")[1] ?? 14}
r="2"
fill={isPositive ? "oklch(0.65 0.19 145)" : "oklch(0.577 0.245 27.325)"}
/>
</svg> </svg>
</div> </div>
{/if} {/if}
</div> </div>
</Card.Content> </div>
</Card.Root>

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -20,86 +19,88 @@ const hours = [
]; ];
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
// Mock heatmap data - values from 0-100 representing traffic intensity // Traffic intensity data (0-100)
const heatmapData = [ const heatmapData = [
[20, 35, 45, 60, 75, 55, 30], // 0:00 [20, 35, 45, 60, 75, 55, 30],
[15, 25, 40, 55, 65, 45, 25], // 3:00 [15, 25, 40, 55, 65, 45, 25],
[25, 40, 55, 70, 80, 60, 35], // 6:00 [25, 40, 55, 70, 80, 60, 35],
[35, 50, 65, 80, 90, 70, 45], // 9:00 [35, 50, 65, 80, 90, 70, 45],
[40, 55, 70, 85, 95, 75, 50], // 12:00 [40, 55, 70, 85, 95, 75, 50],
[45, 60, 75, 90, 85, 70, 45], // 15:00 [45, 60, 75, 90, 85, 70, 45],
[50, 65, 80, 75, 70, 55, 40], // 18:00 [50, 65, 80, 75, 70, 55, 40],
[35, 50, 60, 55, 50, 40, 30], // 21:00 [35, 50, 60, 55, 50, 40, 30],
]; ];
function getBarHeight(value: number): number { function getCellOpacity(value: number): number {
return (value / 100) * 40; return 0.15 + (value / 100) * 0.85;
}
function getBarColor(value: number): string {
if (value >= 80) {
return "#374151";
}
if (value >= 60) {
return "#6b7280";
}
if (value >= 40) {
return "#9ca3af";
}
return "#d1d5db";
} }
</script> </script>
<Card.Root class={cn("", className)}>
<Card.Header class="pb-4">
<Card.Title class="text-base font-medium">Peak Traffic Hours</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex gap-4">
<!-- Y-axis labels -->
<div class="flex flex-col justify-between py-1 text-xs text-muted-foreground">
{#each hours as hour}
<span class="flex h-[40px] items-end">{hour}</span>
{/each}
</div>
<!-- Chart area -->
<div class="flex-1">
<div class="flex gap-2">
{#each days as _, dayIndex}
<div class="flex flex-1 flex-col gap-1">
{#each hours as __, hourIndex}
<div class="flex h-[40px] items-end justify-center">
<div <div
class="w-full max-w-[24px] rounded-sm transition-all" class={cn(
style="height: {getBarHeight(heatmapData[hourIndex][dayIndex])}px; background-color: {getBarColor(heatmapData[hourIndex][dayIndex])}" "overflow-hidden rounded-lg border border-border bg-card shadow-card",
></div> className,
)}
>
<div class="border-b border-border px-5 py-3">
<h3 class="text-sm font-semibold text-foreground">Peak Traffic Hours</h3>
</div>
<div class="p-5">
<div class="flex gap-3">
<!-- Y-axis labels -->
<div class="flex flex-col justify-between pr-1">
{#each hours as hour}
<div class="flex h-6 items-center">
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
</div> </div>
{/each} {/each}
</div> </div>
<!-- Grid -->
<div class="flex-1">
<div class="grid grid-cols-7 gap-1">
{#each hours as _, hourIndex}
{#each days as _, dayIndex}
{@const value = heatmapData[hourIndex][dayIndex]}
<div
class="group relative h-6 rounded transition-all duration-150 hover:ring-2 hover:ring-foreground/20"
style="background-color: oklch(0.205 0.006 285 / {getCellOpacity(value)})"
title="{days[dayIndex]} {hours[hourIndex]}: {value}% traffic"
>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-8 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap rounded bg-foreground px-2 py-1 text-[10px] font-medium text-background opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
>
{value}%
</div>
</div>
{/each}
{/each} {/each}
</div> </div>
<!-- X-axis labels --> <!-- X-axis labels -->
<div class="mt-2 flex gap-2"> <div class="mt-2 grid grid-cols-7 gap-1">
{#each days as day} {#each days as day}
<div class="flex-1 text-center text-xs text-muted-foreground">{day}</div> <div class="text-center text-[11px] text-muted-foreground">{day}</div>
{/each} {/each}
</div> </div>
</div> </div>
</div> </div>
<!-- Legend --> <!-- Legend -->
<div class="mt-4 flex items-center justify-center gap-4 text-xs text-muted-foreground"> <div class="mt-5 flex items-center justify-center gap-4">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-sm bg-muted"></div> <div class="flex items-center gap-0.5">
<span>This month</span> {#each [0.2, 0.4, 0.6, 0.8, 1] as opacity}
<div
class="h-2 w-4 first:rounded-l last:rounded-r"
style="background-color: oklch(0.205 0.006 285 / {opacity})"
></div>
{/each}
</div>
<span class="text-[11px] text-muted-foreground">Traffic intensity</span>
</div>
</div> </div>
<div class="flex items-center gap-1.5">
<div class="h-3 w-3 rounded-sm bg-gray-300"></div>
<span>Last month</span>
</div> </div>
</div> </div>
</Card.Content>
</Card.Root>

View File

@@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Badge } from "$lib/components/ui/badge"; import { goto } from "$app/navigation";
import * as Card from "$lib/components/ui/card"; import { page } from "$app/stores";
import * as Table from "$lib/components/ui/table";
import * as Tabs from "$lib/components/ui/tabs";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import {
AdUnitTable,
CountryTable,
DomainTable,
KeyValueTable,
SourceTable,
} from "./tabs";
interface Props { interface Props {
class?: string; class?: string;
@@ -17,125 +22,89 @@ const tabs = [
{ id: "source", label: "Source", count: null }, { id: "source", label: "Source", count: null },
{ id: "ad-unit", label: "Ad Unit", count: null }, { id: "ad-unit", label: "Ad Unit", count: null },
{ id: "key-value", label: "Key Value", count: null }, { id: "key-value", label: "Key Value", count: null },
]; ] as const;
let activeTab = $state("ad-unit"); type TabId = (typeof tabs)[number]["id"];
const tableData = [ const defaultTab: TabId = "ad-unit";
{
id: 1, const activeTab = $derived(
name: "#ad-unitName", ($page.url.searchParams.get("tab") as TabId) || defaultTab,
revenue: "0,000,000", );
revPercent: "22.00%",
impressions: "2,422,000", function handleTabChange(tabId: string) {
impPercent: "####", const url = new URL($page.url);
}, if (tabId === defaultTab) {
{ url.searchParams.delete("tab");
id: 2, } else {
name: "#ad-unitName", url.searchParams.set("tab", tabId);
revenue: "0,000,000", }
revPercent: "22.00%", goto(url.toString(), { replaceState: true, noScroll: true });
impressions: "2,422,000", }
impPercent: "####",
},
{
id: 3,
name: "#ad-unitName",
revenue: "0,000,000",
revPercent: "22.00%",
impressions: "2,422,000",
impPercent: "####",
},
{
id: 4,
name: "#ad-unitName",
revenue: "0,000,000",
revPercent: "22.00%",
impressions: "2,422,000",
impPercent: "####",
},
{
id: 5,
name: "#ad-unitName",
revenue: "0,000,000",
revPercent: "22.00%",
impressions: "2,422,000",
impPercent: "####",
},
{
id: 6,
name: "#ad-unitName",
revenue: "0,000,000",
revPercent: "22.00%",
impressions: "2,422,000",
impPercent: "####",
},
];
</script> </script>
<Card.Root class={cn("", className)}> <div
<Card.Header class="pb-3"> class={cn(
<div class="flex items-center justify-between"> "overflow-hidden rounded-lg border border-border bg-card shadow-card",
className,
)}
>
<!-- Header with tabs -->
<div class="flex items-center justify-between border-b border-border px-5 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Card.Title class="text-base font-medium">Performance by</Card.Title> <h3 class="text-sm font-semibold text-foreground">Performance by</h3>
<svg class="h-4 w-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6" stroke-linecap="round" stroke-linejoin="round" /> <path d="m9 18 6-6-6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<Tabs.Root bind:value={activeTab}> <!-- Tab navigation -->
<Tabs.List class="h-auto gap-1 bg-transparent p-0"> <div class="flex items-center gap-0.5" role="tablist">
{#each tabs as tab} {#each tabs as tab}
<Tabs.Trigger {@const isActive = activeTab === tab.id}
value={tab.id} <button
class="gap-1.5 px-3 py-1.5 text-sm font-normal data-[state=active]:bg-transparent data-[state=active]:font-medium data-[state=active]:shadow-none" role="tab"
aria-selected={isActive}
aria-controls="panel-{tab.id}"
onclick={() => handleTabChange(tab.id)}
class={cn(
"relative flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-all duration-150",
isActive
? "font-medium text-foreground"
: "font-normal text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
> >
{tab.label} {tab.label}
{#if tab.count} {#if tab.count}
<Badge variant="secondary" class="ml-1 h-5 min-w-5 rounded-full px-1.5 text-xs"> <span
class={cn(
"flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1 text-[10px] font-medium",
isActive ? "bg-foreground text-background" : "bg-muted text-muted-foreground",
)}
>
{tab.count} {tab.count}
</Badge> </span>
{/if} {/if}
</Tabs.Trigger> {#if isActive}
<span class="absolute inset-x-2 -bottom-[13px] h-0.5 rounded-full bg-foreground"></span>
{/if}
</button>
{/each} {/each}
</Tabs.List>
</Tabs.Root>
</div> </div>
</Card.Header> </div>
<Card.Content class="p-0"> <!-- Table content -->
<Table.Root> <div id="panel-{activeTab}" role="tabpanel">
<Table.Header> {#if activeTab === "domain"}
<Table.Row class="hover:bg-transparent"> <DomainTable />
<Table.Head class="w-12"></Table.Head> {:else if activeTab === "country"}
<Table.Head class="font-normal text-muted-foreground">Ad unit</Table.Head> <CountryTable />
<Table.Head class="text-right font-normal text-muted-foreground">Total revenue</Table.Head> {:else if activeTab === "source"}
<Table.Head class="text-right font-normal text-muted-foreground"> <SourceTable />
<div class="flex items-center justify-end gap-1"> {:else if activeTab === "ad-unit"}
% of rev. <AdUnitTable />
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> {:else if activeTab === "key-value"}
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" /> <KeyValueTable />
</svg> {/if}
</div>
</div> </div>
</Table.Head>
<Table.Head class="text-right font-normal text-muted-foreground">Total impressions</Table.Head>
<Table.Head class="text-right font-normal text-muted-foreground">% of impressions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row}
<Table.Row>
<Table.Cell class="w-12">
<div class="h-4 w-4 rounded border border-border bg-muted"></div>
</Table.Cell>
<Table.Cell class="font-mono text-sm text-muted-foreground">{row.name}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.revenue}</Table.Cell>
<Table.Cell class="text-right font-semibold tabular-nums">{row.revPercent}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.impressions}</Table.Cell>
<Table.Cell class="text-right tabular-nums text-muted-foreground">{row.impPercent}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
const tableData = [
{
id: 1,
name: "/header/leaderboard-728x90",
revenue: "$1,200,000",
revPercent: 22.0,
impressions: "2,422,000",
impPercent: 18.5,
},
{
id: 2,
name: "/sidebar/medium-rect-300x250",
revenue: "$980,000",
revPercent: 17.96,
impressions: "2,100,000",
impPercent: 16.04,
},
{
id: 3,
name: "/content/in-article-300x250",
revenue: "$850,000",
revPercent: 15.58,
impressions: "1,850,000",
impPercent: 14.13,
},
{
id: 4,
name: "/footer/billboard-970x250",
revenue: "$720,000",
revPercent: 13.2,
impressions: "1,600,000",
impPercent: 12.22,
},
{
id: 5,
name: "/mobile/sticky-320x50",
revenue: "$650,000",
revPercent: 11.92,
impressions: "1,450,000",
impPercent: 11.07,
},
{
id: 6,
name: "/interstitial/fullscreen",
revenue: "$520,000",
revPercent: 9.53,
impressions: "1,200,000",
impPercent: 9.16,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Ad unit</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of revenue
<svg class="h-3 w-3 text-muted-foreground/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m18 15-6-6-6 6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<code class="font-mono text-[13px] text-foreground">{row.name}</code>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -0,0 +1,110 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
const tableData = [
{
id: 1,
name: "United States",
code: "US",
revenue: "$3,200,000",
revPercent: 44.8,
impressions: "15,800,000",
impPercent: 41.0,
},
{
id: 2,
name: "United Kingdom",
code: "GB",
revenue: "$1,100,000",
revPercent: 15.4,
impressions: "5,600,000",
impPercent: 14.55,
},
{
id: 3,
name: "Germany",
code: "DE",
revenue: "$850,000",
revPercent: 11.9,
impressions: "4,200,000",
impPercent: 10.9,
},
{
id: 4,
name: "Canada",
code: "CA",
revenue: "$620,000",
revPercent: 8.7,
impressions: "3,100,000",
impPercent: 8.05,
},
{
id: 5,
name: "Australia",
code: "AU",
revenue: "$480,000",
revPercent: 6.72,
impressions: "2,400,000",
impPercent: 6.24,
},
{
id: 6,
name: "France",
code: "FR",
revenue: "$350,000",
revPercent: 4.9,
impressions: "1,800,000",
impPercent: 4.68,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Country</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<div class="flex items-center gap-2">
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{row.code}</span>
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
const tableData = [
{
id: 1,
name: "example.com",
revenue: "$2,500,000",
revPercent: 35.0,
impressions: "12,500,000",
impPercent: 32.5,
},
{
id: 2,
name: "news-site.org",
revenue: "$1,800,000",
revPercent: 25.2,
impressions: "9,200,000",
impPercent: 23.9,
},
{
id: 3,
name: "blog-network.net",
revenue: "$1,200,000",
revPercent: 16.8,
impressions: "7,100,000",
impPercent: 18.45,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Domain</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -0,0 +1,5 @@
export { default as AdUnitTable } from "./ad-unit-table.svelte";
export { default as CountryTable } from "./country-table.svelte";
export { default as DomainTable } from "./domain-table.svelte";
export { default as KeyValueTable } from "./key-value-table.svelte";
export { default as SourceTable } from "./source-table.svelte";

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
const tableData = [
{
id: 1,
key: "device",
value: "desktop",
revenue: "$2,100,000",
revPercent: 38.5,
impressions: "10,500,000",
impPercent: 35.0,
},
{
id: 2,
key: "device",
value: "mobile",
revenue: "$1,800,000",
revPercent: 33.0,
impressions: "9,800,000",
impPercent: 32.67,
},
{
id: 3,
key: "device",
value: "tablet",
revenue: "$450,000",
revPercent: 8.25,
impressions: "2,400,000",
impPercent: 8.0,
},
{
id: 4,
key: "section",
value: "news",
revenue: "$680,000",
revPercent: 12.47,
impressions: "3,600,000",
impPercent: 12.0,
},
{
id: 5,
key: "section",
value: "sports",
revenue: "$420,000",
revPercent: 7.7,
impressions: "2,200,000",
impPercent: 7.33,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Key</Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Value</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] font-medium text-muted-foreground">{row.key}</code>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.value}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import * as Table from "$lib/components/ui/table";
const tableData = [
{
id: 1,
name: "Google AdX",
revenue: "$2,800,000",
revPercent: 39.2,
impressions: "14,000,000",
impPercent: 36.4,
},
{
id: 2,
name: "Amazon TAM",
revenue: "$1,500,000",
revPercent: 21.0,
impressions: "7,800,000",
impPercent: 20.28,
},
{
id: 3,
name: "OpenX",
revenue: "$980,000",
revPercent: 13.72,
impressions: "5,200,000",
impPercent: 13.52,
},
{
id: 4,
name: "Prebid",
revenue: "$720,000",
revPercent: 10.08,
impressions: "3,900,000",
impPercent: 10.14,
},
{
id: 5,
name: "Index Exchange",
revenue: "$550,000",
revPercent: 7.7,
impressions: "2,800,000",
impPercent: 7.28,
},
];
function getBarWidth(value: number, max: number): number {
return (value / max) * 100;
}
const maxRevPercent = Math.max(...tableData.map((d) => d.revPercent));
</script>
<Table.Root>
<Table.Header>
<Table.Row class="border-b border-border hover:bg-transparent">
<Table.Head class="h-10 w-10 pl-5"></Table.Head>
<Table.Head class="h-10 text-xs font-medium text-muted-foreground">Source</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Revenue</Table.Head>
<Table.Head class="h-10 w-32 text-right text-xs font-medium text-muted-foreground">% of revenue</Table.Head>
<Table.Head class="h-10 text-right text-xs font-medium text-muted-foreground">Impressions</Table.Head>
<Table.Head class="h-10 pr-5 text-right text-xs font-medium text-muted-foreground">% of impr.</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each tableData as row, i}
<Table.Row class="group border-b border-border/50 transition-colors last:border-0 hover:bg-muted/30">
<Table.Cell class="w-10 py-3 pl-5">
<div class="flex h-4 w-4 items-center justify-center rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground">
{i + 1}
</div>
</Table.Cell>
<Table.Cell class="py-3">
<span class="text-[13px] font-medium text-foreground">{row.name}</span>
</Table.Cell>
<Table.Cell class="py-3 text-right font-medium tabular-nums text-foreground">{row.revenue}</Table.Cell>
<Table.Cell class="w-32 py-3">
<div class="flex items-center justify-end gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-foreground/70 transition-all duration-300"
style="width: {getBarWidth(row.revPercent, maxRevPercent)}%"
></div>
</div>
<span class="w-12 text-right font-medium tabular-nums text-foreground">{row.revPercent.toFixed(2)}%</span>
</div>
</Table.Cell>
<Table.Cell class="py-3 text-right tabular-nums text-muted-foreground">{row.impressions}</Table.Cell>
<Table.Cell class="py-3 pr-5 text-right tabular-nums text-muted-foreground">{row.impPercent.toFixed(2)}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import MobileNav from "./mobile-nav.svelte";
interface Props { interface Props {
title: string; title: string;
@@ -12,57 +13,84 @@ let { title, class: className }: Props = $props();
const filters = [ const filters = [
{ label: "This month", removable: true }, { label: "This month", removable: true },
{ label: "in 6 countries, in 3 domains", removable: true }, { label: "6 countries", removable: true },
{ label: "3 domains", removable: true },
]; ];
</script> </script>
<header <header
class={cn( class={cn(
"flex h-16 items-center justify-between border-b border-border bg-card px-6", "flex h-14 items-center justify-between border-b border-border bg-card px-4 lg:px-6",
className className,
)} )}
> >
<h1 class="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- Filter Badges --> <!-- Mobile menu button -->
<MobileNav class="lg:hidden" />
<h1 class="text-base font-semibold tracking-tight text-foreground lg:text-lg">{title}</h1>
<!-- Filters - hidden on mobile -->
{#if filters.length > 0}
<div class="hidden items-center gap-4 lg:flex">
<Separator orientation="vertical" class="h-5" />
<div class="flex items-center gap-2">
{#each filters as filter} {#each filters as filter}
<Badge variant="secondary" class="gap-1.5 px-3 py-1.5 text-sm font-normal"> <div
{filter.label} class="group flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-muted-foreground/30"
>
<span>{filter.label}</span>
{#if filter.removable} {#if filter.removable}
<button class="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" aria-label="Remove filter"> <button
class="rounded-sm p-0.5 opacity-60 transition-opacity hover:opacity-100"
aria-label="Remove filter"
>
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" /> <path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</button> </button>
{/if} {/if}
</Badge> </div>
{/each} {/each}
</div>
</div>
{/if}
</div>
<div class="flex items-center gap-1">
<!-- Search --> <!-- Search -->
<Button variant="ghost" size="icon" class="h-9 w-9"> <Button variant="ghost" size="icon" class="h-8 w-8 text-muted-foreground hover:text-foreground">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" stroke-linecap="round" /> <path d="m21 21-4.35-4.35" stroke-linecap="round" />
</svg> </svg>
<span class="sr-only">Search</span> <span class="sr-only">Search</span>
</Button> </Button>
<!-- Settings --> <!-- Notifications -->
<Button variant="ghost" size="icon" class="h-9 w-9"> <Button variant="ghost" size="icon" class="relative h-8 w-8 text-muted-foreground hover:text-foreground">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="3" /> <path
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" stroke-linecap="round" /> d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
<span class="sr-only">Settings</span> <span class="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-chart-5"></span>
<span class="sr-only">Notifications</span>
</Button> </Button>
<!-- Create New Button --> <!-- Create button - hidden on small mobile -->
<Button class="gap-2"> <div class="hidden items-center sm:flex">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Separator orientation="vertical" class="mx-2 h-5" />
<Button size="sm" class="h-8 gap-1.5 px-3 text-xs font-medium">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" stroke-linecap="round" /> <path d="M12 5v14M5 12h14" stroke-linecap="round" />
</svg> </svg>
Create new <span class="hidden sm:inline">New report</span>
</Button> </Button>
</div> </div>
</div>
</header> </header>

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores";
import { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
interface Props { interface Props {
@@ -8,80 +10,84 @@ interface Props {
let { class: className }: Props = $props(); let { class: className }: Props = $props();
const navItems = [ const navItems = [
{ icon: "home", href: "/", label: "Home" }, {
{ icon: "chart", href: "/performance", label: "Performance" }, icon: "home",
{ icon: "document", href: "/reports", label: "Reports" }, href: "/",
label: "Home",
},
{
icon: "chart",
href: "/performance",
label: "Performance",
},
{
icon: "document",
href: "/reports",
label: "Reports",
},
];
const bottomItems = [
{
icon: "settings",
href: "/settings",
label: "Settings",
},
]; ];
</script> </script>
<aside <aside
class={cn( class={cn(
"flex h-screen w-16 flex-col items-center border-r border-border bg-card py-4", "flex h-screen w-[72px] flex-col items-center bg-sidebar py-5",
className "border-r border-sidebar-border",
className,
)} )}
> >
<!-- Logo --> <!-- Logo -->
<a <a
href="/" href="/"
aria-label="Home" aria-label="Home"
class="mb-8 flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-orange-600 shadow-md transition-transform hover:scale-105" class="group flex h-10 w-10 items-center justify-center rounded-lg bg-foreground transition-all duration-200 hover:scale-[1.02]"
> >
<svg <svg
class="h-6 w-6 text-white" class="h-5 w-5 text-background transition-transform duration-200 group-hover:scale-105"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
stroke-width="2" stroke-width="2.5"
> >
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" /> <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</a> </a>
<!-- Navigation --> <Separator class="my-5 w-8" />
<nav class="flex flex-1 flex-col items-center gap-2">
<!-- Main Navigation -->
<nav class="flex flex-1 flex-col items-center gap-1">
{#each navItems as item} {#each navItems as item}
{@const isActive = $page.url.pathname === item.href || (item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a <a
href={item.href} href={item.href}
class="group relative flex h-10 w-10 items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-accent hover:text-foreground" class={cn(
"group relative flex h-10 w-10 items-center justify-center rounded-lg transition-all duration-150",
isActive
? "bg-sidebar-accent text-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
)}
aria-label={item.label} aria-label={item.label}
aria-current={isActive ? "page" : undefined}
> >
{#if item.icon === "home"} {#if item.icon === "home"}
<svg <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
class="h-5 w-5" <path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" stroke-linecap="round" stroke-linejoin="round" />
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M9 22V12h6v10" stroke-linecap="round" stroke-linejoin="round" /> <path d="M9 22V12h6v10" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
{:else if item.icon === "chart"} {:else if item.icon === "chart"}
<svg <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
class="h-5 w-5" <path d="M18 20V10M12 20V4M6 20v-6" stroke-linecap="round" stroke-linejoin="round" />
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M18 20V10M12 20V4M6 20v-6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg> </svg>
{:else if item.icon === "document"} {:else if item.icon === "document"}
<svg <svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path <path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"
stroke-linecap="round" stroke-linecap="round"
@@ -93,18 +99,59 @@ const navItems = [
<!-- Tooltip --> <!-- Tooltip -->
<span <span
class="pointer-events-none absolute left-full ml-2 whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover:opacity-100" class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
>
{item.label}
</span>
<!-- Active indicator -->
{#if isActive}
<span class="absolute -left-[1px] h-5 w-[3px] rounded-r-full bg-foreground"></span>
{/if}
</a>
{/each}
</nav>
<!-- Bottom Navigation -->
<div class="flex flex-col items-center gap-1">
{#each bottomItems as item}
{@const isActive = $page.url.pathname === item.href}
<a
href={item.href}
class={cn(
"group relative flex h-10 w-10 items-center justify-center rounded-lg transition-all duration-150",
isActive
? "bg-sidebar-accent text-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
)}
aria-label={item.label}
aria-current={isActive ? "page" : undefined}
>
{#if item.icon === "settings"}
<svg class="h-[18px] w-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
<span
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
> >
{item.label} {item.label}
</span> </span>
</a> </a>
{/each} {/each}
</nav>
<Separator class="my-3 w-8" />
<!-- User Avatar --> <!-- User Avatar -->
<div class="mt-auto">
<button <button
class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-emerald-400 to-teal-500 text-sm font-medium text-white shadow-sm transition-transform hover:scale-105" class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-chart-1 to-chart-2 text-xs font-semibold text-white shadow-sm ring-2 ring-background transition-transform duration-150 hover:scale-105"
aria-label="User menu"
> >
JD JD
</button> </button>

View File

@@ -14,12 +14,15 @@ let { title, children, class: className }: Props = $props();
</script> </script>
<div class="flex h-screen overflow-hidden bg-background"> <div class="flex h-screen overflow-hidden bg-background">
<!-- Desktop sidebar - hidden on mobile -->
<div class="hidden lg:block">
<AppSidebar /> <AppSidebar />
</div>
<div class="flex flex-1 flex-col overflow-hidden"> <div class="flex flex-1 flex-col overflow-hidden">
<AppHeader {title} /> <AppHeader {title} />
<main class={cn("flex-1 overflow-auto p-6", className)}> <main class={cn("flex-1 overflow-auto p-4 lg:p-6", className)}>
{@render children()} {@render children()}
</main> </main>
</div> </div>

View File

@@ -1,3 +1,4 @@
export { default as AppHeader } from "./app-header.svelte"; export { default as AppHeader } from "./app-header.svelte";
export { default as AppSidebar } from "./app-sidebar.svelte"; export { default as AppSidebar } from "./app-sidebar.svelte";
export { default as DashboardLayout } from "./dashboard-layout.svelte"; export { default as DashboardLayout } from "./dashboard-layout.svelte";
export { default as MobileNav } from "./mobile-nav.svelte";

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { page } from "$app/stores";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import * as Sheet from "$lib/components/ui/sheet";
import { cn } from "$lib/utils.js";
interface Props {
class?: string;
}
let { class: className }: Props = $props();
let open = $state(false);
const navItems = [
{ icon: "home", href: "/", label: "Home" },
{ icon: "chart", href: "/performance", label: "Performance" },
{ icon: "document", href: "/reports", label: "Reports" },
];
const bottomItems = [
{ icon: "settings", href: "/settings", label: "Settings" },
];
function handleNavClick() {
open = false;
}
</script>
<Sheet.Root bind:open>
<Sheet.Trigger asChild>
{#snippet child({ props })}
<Button variant="ghost" size="icon" class={cn("h-9 w-9 lg:hidden", className)} {...props}>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 12h18M3 6h18M3 18h18" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="sr-only">Open menu</span>
</Button>
{/snippet}
</Sheet.Trigger>
<Sheet.Content side="left" class="w-72 p-0">
<Sheet.Header class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-foreground">
<svg class="h-5 w-5 text-background" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<Sheet.Title class="text-lg font-semibold">Publisher Dashboard</Sheet.Title>
</div>
</Sheet.Header>
<nav class="flex flex-1 flex-col p-4">
<div class="space-y-1">
{#each navItems as item}
{@const isActive =
$page.url.pathname === item.href ||
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a
href={item.href}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{#if item.icon === "home"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 22V12h6v10" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "chart"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M18 20V10M12 20V4M6 20v-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if item.icon === "document"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
{item.label}
</a>
{/each}
</div>
<Separator class="my-4" />
<div class="space-y-1">
{#each bottomItems as item}
{@const isActive = $page.url.pathname === item.href}
<a
href={item.href}
onclick={handleNavClick}
class={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{#if item.icon === "settings"}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
{item.label}
</a>
{/each}
</div>
<!-- User section at bottom -->
<div class="mt-auto pt-4">
<Separator class="mb-4" />
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-chart-1 to-chart-2 text-xs font-semibold text-white">
JD
</div>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">John Doe</p>
<p class="text-xs text-muted-foreground">john@example.com</p>
</div>
</div>
</div>
</nav>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,34 @@
import Root from "./sheet.svelte";
import Close from "./sheet-close.svelte";
import Content from "./sheet-content.svelte";
import Description from "./sheet-description.svelte";
import Footer from "./sheet-footer.svelte";
import Header from "./sheet-header.svelte";
import Overlay from "./sheet-overlay.svelte";
import Portal from "./sheet-portal.svelte";
import Title from "./sheet-title.svelte";
import Trigger from "./sheet-trigger.svelte";
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps =
$props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,62 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom:
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
right:
"data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPortal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ...restProps }: SheetPrimitive.PortalProps = $props();
</script>
<SheetPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps =
$props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: SheetPrimitive.RootProps =
$props();
</script>
<SheetPrimitive.Root bind:open {...restProps} />

View File

@@ -39,8 +39,9 @@ const metrics = [
<DashboardLayout title="SSP Network Performance"> <DashboardLayout title="SSP Network Performance">
<div class="space-y-6"> <div class="space-y-6">
<!-- Metric Cards Row --> <!-- Metric Cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <section>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#each metrics as metric} {#each metrics as metric}
<MetricCard <MetricCard
label={metric.label} label={metric.label}
@@ -50,19 +51,22 @@ const metrics = [
/> />
{/each} {/each}
</div> </div>
</section>
<!-- Main Content Grid --> <!-- Main Content -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> <section>
<!-- Performance Table - 2 columns --> <div class="grid grid-cols-1 gap-6 xl:grid-cols-12">
<div class="lg:col-span-2"> <!-- Performance Table -->
<div class="xl:col-span-8">
<PerformanceTable /> <PerformanceTable />
</div> </div>
<!-- Right Sidebar --> <!-- Sidebar widgets -->
<div class="space-y-6"> <div class="space-y-6 xl:col-span-4">
<PeakTrafficChart /> <PeakTrafficChart />
<FrequentFilters /> <FrequentFilters />
</div> </div>
</div> </div>
</section>
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@@ -24,7 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.10.1", "@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0", "@lucide/svelte": "^0.561.0",
"@macalinao/tsconfig": "catalog:", "@macalinao/tsconfig": "catalog:",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1", "@sveltejs/kit": "^2.21.1",
@@ -142,7 +142,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/svelte": ["@lucide/svelte@0.562.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-wDMULwtTFN2Sc/TFBm6gfuVCNb4Y5P9LDrwxNnUbV52+IEU7NXZmvxwXoz+vrrpad6Xupq+Hw5eUlqIHEGhouw=="], "@lucide/svelte": ["@lucide/svelte@0.561.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-vofKV2UFVrKE6I4ewKJ3dfCXSV6iP6nWVmiM83MLjsU91EeJcEg7LoWUABLp/aOTxj1HQNbJD1f3g3L0JQgH9A=="],
"@macalinao/biome-config": ["@macalinao/biome-config@0.1.7", "", { "peerDependencies": { "@biomejs/biome": "^2.3.10" } }, "sha512-JijaB/REJr6D3fGV36d1XGsf2WFofgnMS1WbOYcNJCQpic2XmFALV7GNL28z7rDCN3/DeSovPuW/1yImce7kPA=="], "@macalinao/biome-config": ["@macalinao/biome-config@0.1.7", "", { "peerDependencies": { "@biomejs/biome": "^2.3.10" } }, "sha512-JijaB/REJr6D3fGV36d1XGsf2WFofgnMS1WbOYcNJCQpic2XmFALV7GNL28z7rDCN3/DeSovPuW/1yImce7kPA=="],