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": {
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0",
"@lucide/svelte": "^0.561.0",
"@macalinao/tsconfig": "catalog:",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1",

View File

@@ -1,83 +1,170 @@
@import "tailwindcss";
@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 *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--radius: 0.5rem;
/* 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-foreground: oklch(0.141 0.005 285.823);
--card-foreground: oklch(0.145 0.005 285);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.145 0.005 285);
--primary: oklch(0.205 0.006 285);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.96 0.002 280);
--secondary-foreground: oklch(0.205 0.006 285);
--muted: oklch(0.96 0.002 280);
--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);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--border: oklch(0.91 0.003 280);
--input: oklch(0.91 0.003 280);
--ring: oklch(0.65 0.015 280);
/* Accent colors for data viz */
--success: oklch(0.65 0.19 145);
--warning: oklch(0.75 0.18 65);
--chart-1: oklch(0.55 0.2 250);
--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-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--sidebar-accent: oklch(0.94 0.002 280);
--sidebar-accent-foreground: oklch(0.205 0.006 285);
--sidebar-border: oklch(0.91 0.003 280);
--sidebar-ring: oklch(0.65 0.015 280);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--background: oklch(0.12 0.005 280);
--foreground: oklch(0.96 0 0);
--card: oklch(0.16 0.005 280);
--card-foreground: oklch(0.96 0 0);
--popover: oklch(0.16 0.005 280);
--popover-foreground: oklch(0.96 0 0);
--primary: oklch(0.92 0.003 280);
--primary-foreground: oklch(0.12 0.005 280);
--secondary: oklch(0.22 0.005 280);
--secondary-foreground: oklch(0.96 0 0);
--muted: oklch(0.22 0.005 280);
--muted-foreground: oklch(0.6 0.01 280);
--accent: oklch(0.22 0.005 280);
--accent-foreground: oklch(0.96 0 0);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.26 0.005 280);
--input: oklch(0.26 0.005 280);
--ring: oklch(0.5 0.01 280);
--success: oklch(0.7 0.2 145);
--warning: oklch(0.8 0.18 65);
--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 {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, monospace;
--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-foreground: var(--foreground);
--color-card: var(--card);
@@ -96,6 +183,8 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-success: var(--success);
--color-warning: var(--warning);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -113,13 +202,57 @@
@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 {
@apply bg-background text-foreground;
@apply bg-background font-sans text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
button,
[role="button"] {
@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">
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils.js";
interface Props {
@@ -9,24 +8,59 @@ interface Props {
let { class: className }: Props = $props();
const filters = [
{ label: "For 9 countries, on weekends", href: "#" },
{ label: "North America region", href: "#" },
{ label: "9 countries, weekends", icon: "globe", href: "#" },
{ label: "North America region", icon: "map", href: "#" },
{ label: "Mobile devices only", icon: "mobile", href: "#" },
];
</script>
<Card.Root class={cn("", className)}>
<Card.Header class="pb-3">
<Card.Title class="text-sm font-medium text-muted-foreground">Frequent filters</Card.Title>
</Card.Header>
<div
class={cn(
"overflow-hidden rounded-lg border border-border bg-card shadow-card",
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}
<a
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>
{/each}
</Card.Content>
</Card.Root>
</div>
<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">
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils.js";
interface Props {
@@ -19,19 +18,17 @@ let {
}: Props = $props();
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 {
if (data.length < 2) {
return "";
}
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const width = 80;
const height = 32;
const width = 64;
const height = 28;
const padding = 2;
const points = data.map((val, i) => {
@@ -43,36 +40,102 @@ function generateSparklinePath(data: number[]): string {
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 areaPath = $derived(generateAreaPath(sparklineData));
</script>
<Card.Root class={cn("relative overflow-hidden", className)}>
<Card.Content class="p-4">
<div class="flex items-start justify-between">
<div class="space-y-1">
<p class="text-sm text-muted-foreground">{label}</p>
<div class="flex items-baseline gap-2">
<span class="text-2xl font-semibold tracking-tight">{value}</span>
<span class={cn("text-sm font-medium", changeColor)}>
{isPositive ? "+" : ""}{change.toFixed(2)}%
</span>
</div>
</div>
<div
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="space-y-2">
<p class="text-[13px] font-medium text-muted-foreground">{label}</p>
{#if sparklineData.length > 0}
<div class="flex-shrink-0">
<svg width="80" height="32" class="overflow-visible">
<path
d={sparklinePath}
fill="none"
stroke={sparklineColor}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<div class="flex items-baseline gap-2.5">
<span class="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
<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>
</div>
{/if}
{Math.abs(change).toFixed(2)}%
</span>
</div>
</div>
</Card.Content>
</Card.Root>
{#if sparklineData.length > 0}
<div class="mt-1 flex-shrink-0 opacity-80 transition-opacity group-hover:opacity-100">
<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
d={sparklinePath}
fill="none"
stroke={isPositive ? "oklch(0.65 0.19 145)" : "oklch(0.577 0.245 27.325)"}
stroke-width="1.5"
stroke-linecap="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>
</div>
{/if}
</div>
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import * as Card from "$lib/components/ui/card";
import { cn } from "$lib/utils.js";
interface Props {
@@ -20,86 +19,88 @@ const hours = [
];
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 = [
[20, 35, 45, 60, 75, 55, 30], // 0:00
[15, 25, 40, 55, 65, 45, 25], // 3:00
[25, 40, 55, 70, 80, 60, 35], // 6:00
[35, 50, 65, 80, 90, 70, 45], // 9:00
[40, 55, 70, 85, 95, 75, 50], // 12:00
[45, 60, 75, 90, 85, 70, 45], // 15:00
[50, 65, 80, 75, 70, 55, 40], // 18:00
[35, 50, 60, 55, 50, 40, 30], // 21:00
[20, 35, 45, 60, 75, 55, 30],
[15, 25, 40, 55, 65, 45, 25],
[25, 40, 55, 70, 80, 60, 35],
[35, 50, 65, 80, 90, 70, 45],
[40, 55, 70, 85, 95, 75, 50],
[45, 60, 75, 90, 85, 70, 45],
[50, 65, 80, 75, 70, 55, 40],
[35, 50, 60, 55, 50, 40, 30],
];
function getBarHeight(value: number): number {
return (value / 100) * 40;
}
function getBarColor(value: number): string {
if (value >= 80) {
return "#374151";
}
if (value >= 60) {
return "#6b7280";
}
if (value >= 40) {
return "#9ca3af";
}
return "#d1d5db";
function getCellOpacity(value: number): number {
return 0.15 + (value / 100) * 0.85;
}
</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>
<div
class={cn(
"overflow-hidden rounded-lg border border-border bg-card shadow-card",
className,
)}
>
<div class="border-b border-border px-5 py-3">
<h3 class="text-sm font-semibold text-foreground">Peak Traffic Hours</h3>
</div>
<Card.Content>
<div class="flex gap-4">
<div class="p-5">
<div class="flex gap-3">
<!-- Y-axis labels -->
<div class="flex flex-col justify-between py-1 text-xs text-muted-foreground">
<div class="flex flex-col justify-between pr-1">
{#each hours as hour}
<span class="flex h-[40px] items-end">{hour}</span>
<div class="flex h-6 items-center">
<span class="text-[11px] tabular-nums text-muted-foreground">{hour}</span>
</div>
{/each}
</div>
<!-- Chart area -->
<!-- Grid -->
<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
class="w-full max-w-[24px] rounded-sm transition-all"
style="height: {getBarHeight(heatmapData[hourIndex][dayIndex])}px; background-color: {getBarColor(heatmapData[hourIndex][dayIndex])}"
></div>
<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>
{/each}
</div>
</div>
{/each}
{/each}
</div>
<!-- X-axis labels -->
<div class="mt-2 flex gap-2">
<div class="mt-2 grid grid-cols-7 gap-1">
{#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}
</div>
</div>
</div>
<!-- Legend -->
<div class="mt-4 flex items-center justify-center gap-4 text-xs text-muted-foreground">
<div class="flex items-center gap-1.5">
<div class="h-3 w-3 rounded-sm bg-muted"></div>
<span>This month</span>
</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 class="mt-5 flex items-center justify-center gap-4">
<div class="flex items-center gap-2">
<div class="flex items-center gap-0.5">
{#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>
</Card.Content>
</Card.Root>
</div>
</div>

View File

@@ -1,9 +1,14 @@
<script lang="ts">
import { Badge } from "$lib/components/ui/badge";
import * as Card from "$lib/components/ui/card";
import * as Table from "$lib/components/ui/table";
import * as Tabs from "$lib/components/ui/tabs";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { cn } from "$lib/utils.js";
import {
AdUnitTable,
CountryTable,
DomainTable,
KeyValueTable,
SourceTable,
} from "./tabs";
interface Props {
class?: string;
@@ -17,125 +22,89 @@ const tabs = [
{ id: "source", label: "Source", count: null },
{ id: "ad-unit", label: "Ad Unit", 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 = [
{
id: 1,
name: "#ad-unitName",
revenue: "0,000,000",
revPercent: "22.00%",
impressions: "2,422,000",
impPercent: "####",
},
{
id: 2,
name: "#ad-unitName",
revenue: "0,000,000",
revPercent: "22.00%",
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: "####",
},
];
const defaultTab: TabId = "ad-unit";
const activeTab = $derived(
($page.url.searchParams.get("tab") as TabId) || defaultTab,
);
function handleTabChange(tabId: string) {
const url = new URL($page.url);
if (tabId === defaultTab) {
url.searchParams.delete("tab");
} else {
url.searchParams.set("tab", tabId);
}
goto(url.toString(), { replaceState: true, noScroll: true });
}
</script>
<Card.Root class={cn("", className)}>
<Card.Header class="pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Card.Title class="text-base font-medium">Performance by</Card.Title>
<svg class="h-4 w-4 text-muted-foreground" 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" />
</svg>
</div>
<Tabs.Root bind:value={activeTab}>
<Tabs.List class="h-auto gap-1 bg-transparent p-0">
{#each tabs as tab}
<Tabs.Trigger
value={tab.id}
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"
>
{tab.label}
{#if tab.count}
<Badge variant="secondary" class="ml-1 h-5 min-w-5 rounded-full px-1.5 text-xs">
{tab.count}
</Badge>
{/if}
</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
<div
class={cn(
"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">
<h3 class="text-sm font-semibold text-foreground">Performance by</h3>
<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" />
</svg>
</div>
</Card.Header>
<Card.Content class="p-0">
<Table.Root>
<Table.Header>
<Table.Row class="hover:bg-transparent">
<Table.Head class="w-12"></Table.Head>
<Table.Head class="font-normal text-muted-foreground">Ad unit</Table.Head>
<Table.Head class="text-right font-normal text-muted-foreground">Total revenue</Table.Head>
<Table.Head class="text-right font-normal text-muted-foreground">
<div class="flex items-center justify-end gap-1">
% of rev.
<svg class="h-3 w-3" 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="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>
<!-- Tab navigation -->
<div class="flex items-center gap-0.5" role="tablist">
{#each tabs as tab}
{@const isActive = activeTab === tab.id}
<button
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}
{#if tab.count}
<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}
</span>
{/if}
{#if isActive}
<span class="absolute inset-x-2 -bottom-[13px] h-0.5 rounded-full bg-foreground"></span>
{/if}
</button>
{/each}
</div>
</div>
<!-- Table content -->
<div id="panel-{activeTab}" role="tabpanel">
{#if activeTab === "domain"}
<DomainTable />
{:else if activeTab === "country"}
<CountryTable />
{:else if activeTab === "source"}
<SourceTable />
{:else if activeTab === "ad-unit"}
<AdUnitTable />
{:else if activeTab === "key-value"}
<KeyValueTable />
{/if}
</div>
</div>

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">
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils.js";
import MobileNav from "./mobile-nav.svelte";
interface Props {
title: string;
@@ -12,57 +13,84 @@ let { title, class: className }: Props = $props();
const filters = [
{ 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>
<header
class={cn(
"flex h-16 items-center justify-between border-b border-border bg-card px-6",
className
"flex h-14 items-center justify-between border-b border-border bg-card px-4 lg:px-6",
className,
)}
>
<h1 class="text-xl font-semibold tracking-tight text-foreground">{title}</h1>
<div class="flex items-center gap-3">
<!-- Filter Badges -->
{#each filters as filter}
<Badge variant="secondary" class="gap-1.5 px-3 py-1.5 text-sm font-normal">
{filter.label}
{#if filter.removable}
<button class="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5" aria-label="Remove filter">
<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" />
</svg>
</button>
{/if}
</Badge>
{/each}
<!-- 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}
<div
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}
<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">
<path d="M18 6L6 18M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="flex items-center gap-1">
<!-- Search -->
<Button variant="ghost" size="icon" class="h-9 w-9">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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="1.75">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" stroke-linecap="round" />
</svg>
<span class="sr-only">Search</span>
</Button>
<!-- Settings -->
<Button variant="ghost" size="icon" class="h-9 w-9">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3" />
<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" />
<!-- Notifications -->
<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="1.75">
<path
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>
<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>
<!-- Create New Button -->
<Button class="gap-2">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" stroke-linecap="round" />
</svg>
Create new
</Button>
<!-- Create button - hidden on small mobile -->
<div class="hidden items-center sm:flex">
<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" />
</svg>
<span class="hidden sm:inline">New report</span>
</Button>
</div>
</div>
</header>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { page } from "$app/stores";
import { Separator } from "$lib/components/ui/separator";
import { cn } from "$lib/utils.js";
interface Props {
@@ -8,80 +10,84 @@ interface Props {
let { class: className }: Props = $props();
const navItems = [
{ icon: "home", href: "/", label: "Home" },
{ icon: "chart", href: "/performance", label: "Performance" },
{ icon: "document", href: "/reports", label: "Reports" },
{
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",
},
];
</script>
<aside
class={cn(
"flex h-screen w-16 flex-col items-center border-r border-border bg-card py-4",
className
"flex h-screen w-[72px] flex-col items-center bg-sidebar py-5",
"border-r border-sidebar-border",
className,
)}
>
<!-- Logo -->
<a
href="/"
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
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"
fill="none"
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>
</a>
<!-- Navigation -->
<nav class="flex flex-1 flex-col items-center gap-2">
<Separator class="my-5 w-8" />
<!-- Main Navigation -->
<nav class="flex flex-1 flex-col items-center gap-1">
{#each navItems as item}
{@const isActive = $page.url.pathname === item.href || (item.href !== "/" && $page.url.pathname.startsWith(item.href))}
<a
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-current={isActive ? "page" : undefined}
>
{#if item.icon === "home"}
<svg
class="h-5 w-5"
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"
/>
<svg class="h-[18px] w-[18px]" 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.5"
>
<path
d="M18 20V10M12 20V4M6 20v-6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<svg class="h-[18px] w-[18px]" 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.5"
>
<svg class="h-[18px] w-[18px]" 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"
@@ -93,18 +99,59 @@ const navItems = [
<!-- Tooltip -->
<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}
</span>
</a>
{/each}
</nav>
<!-- User Avatar -->
<div class="mt-auto">
<Separator class="my-3 w-8" />
<!-- User Avatar -->
<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
</button>

View File

@@ -14,12 +14,15 @@ let { title, children, class: className }: Props = $props();
</script>
<div class="flex h-screen overflow-hidden bg-background">
<AppSidebar />
<!-- Desktop sidebar - hidden on mobile -->
<div class="hidden lg:block">
<AppSidebar />
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<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()}
</main>
</div>

View File

@@ -1,3 +1,4 @@
export { default as AppHeader } from "./app-header.svelte";
export { default as AppSidebar } from "./app-sidebar.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,30 +39,34 @@ const metrics = [
<DashboardLayout title="SSP Network Performance">
<div class="space-y-6">
<!-- Metric Cards Row -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each metrics as metric}
<MetricCard
label={metric.label}
value={metric.value}
change={metric.change}
sparklineData={metric.sparklineData}
/>
{/each}
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Performance Table - 2 columns -->
<div class="lg:col-span-2">
<PerformanceTable />
<!-- Metric Cards -->
<section>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#each metrics as metric}
<MetricCard
label={metric.label}
value={metric.value}
change={metric.change}
sparklineData={metric.sparklineData}
/>
{/each}
</div>
</section>
<!-- Right Sidebar -->
<div class="space-y-6">
<PeakTrafficChart />
<FrequentFilters />
<!-- Main Content -->
<section>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-12">
<!-- Performance Table -->
<div class="xl:col-span-8">
<PerformanceTable />
</div>
<!-- Sidebar widgets -->
<div class="space-y-6 xl:col-span-4">
<PeakTrafficChart />
<FrequentFilters />
</div>
</div>
</div>
</section>
</div>
</DashboardLayout>

View File

@@ -24,7 +24,7 @@
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.562.0",
"@lucide/svelte": "^0.561.0",
"@macalinao/tsconfig": "catalog:",
"@sveltejs/adapter-static": "^3.0.8",
"@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=="],
"@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=="],