Initial commit: Publisher dashboard monorepo
Turborepo + Bun monorepo with: - apps/publisher-dashboard: Svelte 5 SPA with SvelteKit, Tailwind CSS v4, shadcn-svelte - packages/publisher-utils: Shared TypeScript utilities Features: - Performance dashboard page with metrics, charts, and data tables - shadcn-svelte UI components with OKLCH color system - Biome for linting/formatting with Svelte-specific overrides 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
96
apps/publisher-dashboard/README.md
Normal file
96
apps/publisher-dashboard/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Publisher Dashboard
|
||||
|
||||
A Svelte 5 SPA built with SvelteKit, Tailwind CSS v4, and shadcn-svelte.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# From the monorepo root
|
||||
bun run dev
|
||||
|
||||
# Or from this directory
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Adding shadcn-svelte Components
|
||||
|
||||
This project uses [shadcn-svelte](https://shadcn-svelte.com/) for UI components.
|
||||
|
||||
### Adding a new component
|
||||
|
||||
From the `apps/publisher-dashboard` directory, run:
|
||||
|
||||
```bash
|
||||
bunx shadcn-svelte@latest add <component-name>
|
||||
```
|
||||
|
||||
For example, to add the Button component:
|
||||
|
||||
```bash
|
||||
bunx shadcn-svelte@latest add button
|
||||
```
|
||||
|
||||
To add multiple components at once:
|
||||
|
||||
```bash
|
||||
bunx shadcn-svelte@latest add button card input
|
||||
```
|
||||
|
||||
### Available components
|
||||
|
||||
See the full list at: https://shadcn-svelte.com/docs/components
|
||||
|
||||
Common components:
|
||||
- `button` - Buttons with variants
|
||||
- `card` - Card containers
|
||||
- `input` - Text inputs
|
||||
- `label` - Form labels
|
||||
- `select` - Select dropdowns
|
||||
- `dialog` - Modal dialogs
|
||||
- `dropdown-menu` - Dropdown menus
|
||||
- `table` - Data tables
|
||||
- `tabs` - Tab navigation
|
||||
- `toast` / `sonner` - Toast notifications
|
||||
|
||||
### Using components
|
||||
|
||||
After adding a component, import it from `$lib/components/ui`:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
</script>
|
||||
|
||||
<Button variant="outline">Click me</Button>
|
||||
```
|
||||
|
||||
### Utility function
|
||||
|
||||
The `cn()` utility for merging Tailwind classes is available at `$lib/utils`:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
</script>
|
||||
|
||||
<div class={cn("p-4", someCondition && "bg-primary")}>
|
||||
Content
|
||||
</div>
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.css # Global styles + Tailwind + shadcn theme
|
||||
├── app.html # HTML template
|
||||
├── lib/
|
||||
│ ├── components/
|
||||
│ │ └── ui/ # shadcn-svelte components go here
|
||||
│ └── utils/
|
||||
│ └── cn.ts # Tailwind class merge utility
|
||||
└── routes/ # SvelteKit file-based routing
|
||||
├── +layout.svelte
|
||||
├── +layout.ts
|
||||
└── +page.svelte
|
||||
```
|
||||
16
apps/publisher-dashboard/components.json
Normal file
16
apps/publisher-dashboard/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
35
apps/publisher-dashboard/package.json
Normal file
35
apps/publisher-dashboard/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@publisher/dashboard",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@publisher/utils": "workspace:*",
|
||||
"bits-ui": "^2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tslib": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.562.0",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.21.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"svelte": "^5.28.2",
|
||||
"svelte-check": "^4.2.1",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
121
apps/publisher-dashboard/src/app.css
Normal file
121
apps/publisher-dashboard/src/app.css
Normal file
@@ -0,0 +1,121 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--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);
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
13
apps/publisher-dashboard/src/app.d.ts
vendored
Normal file
13
apps/publisher-dashboard/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
apps/publisher-dashboard/src/app.html
Normal file
12
apps/publisher-dashboard/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
const filters = [
|
||||
{ label: "For 9 countries, on weekends", href: "#" },
|
||||
{ label: "North America region", 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>
|
||||
|
||||
<Card.Content class="space-y-2">
|
||||
{#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"
|
||||
>
|
||||
{filter.label}
|
||||
</a>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as FrequentFilters } from "./frequent-filters.svelte";
|
||||
export { default as MetricCard } from "./metric-card.svelte";
|
||||
export { default as PeakTrafficChart } from "./peak-traffic-chart.svelte";
|
||||
export { default as PerformanceTable } from "./performance-table.svelte";
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
change: number;
|
||||
sparklineData?: number[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
change,
|
||||
sparklineData = [],
|
||||
class: className,
|
||||
}: 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 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 `M${points.join(" L")}`;
|
||||
}
|
||||
|
||||
const sparklinePath = $derived(generateSparklinePath(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>
|
||||
|
||||
{#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"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
const hours = [
|
||||
"0:00",
|
||||
"3:00",
|
||||
"6:00",
|
||||
"9:00",
|
||||
"12:00",
|
||||
"15:00",
|
||||
"18:00",
|
||||
"21:00",
|
||||
];
|
||||
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
// Mock heatmap data - values from 0-100 representing traffic intensity
|
||||
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
|
||||
];
|
||||
|
||||
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";
|
||||
}
|
||||
</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
|
||||
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>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<div class="mt-2 flex gap-2">
|
||||
{#each days as day}
|
||||
<div class="flex-1 text-center text-xs 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>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,141 @@
|
||||
<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 { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
const tabs = [
|
||||
{ id: "domain", label: "Domain", count: 3 },
|
||||
{ id: "country", label: "Country", count: 6 },
|
||||
{ id: "source", label: "Source", count: null },
|
||||
{ id: "ad-unit", label: "Ad Unit", count: null },
|
||||
{ id: "key-value", label: "Key Value", count: null },
|
||||
];
|
||||
|
||||
let activeTab = $state("ad-unit");
|
||||
|
||||
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: "####",
|
||||
},
|
||||
];
|
||||
</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>
|
||||
</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>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, class: className }: Props = $props();
|
||||
|
||||
const filters = [
|
||||
{ label: "This month", removable: true },
|
||||
{ label: "in 6 countries, in 3 domains", removable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<header
|
||||
class={cn(
|
||||
"flex h-16 items-center justify-between border-b border-border bg-card 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}
|
||||
|
||||
<!-- 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">
|
||||
<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" />
|
||||
</svg>
|
||||
<span class="sr-only">Settings</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>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
const navItems = [
|
||||
{ icon: "home", href: "/", label: "Home" },
|
||||
{ icon: "chart", href: "/performance", label: "Performance" },
|
||||
{ icon: "document", href: "/reports", label: "Reports" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class={cn(
|
||||
"flex h-screen w-16 flex-col items-center border-r border-border bg-card py-4",
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex flex-1 flex-col items-center gap-2">
|
||||
{#each navItems as item}
|
||||
<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"
|
||||
aria-label={item.label}
|
||||
>
|
||||
{#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"
|
||||
/>
|
||||
<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>
|
||||
{:else if item.icon === "document"}
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<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}
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- User Avatar -->
|
||||
<div class="mt-auto">
|
||||
<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"
|
||||
>
|
||||
JD
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import AppHeader from "./app-header.svelte";
|
||||
import AppSidebar from "./app-sidebar.svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { title, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen overflow-hidden bg-background">
|
||||
<AppSidebar />
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<AppHeader {title} />
|
||||
|
||||
<main class={cn("flex-1 overflow-auto p-6", className)}>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as AppHeader } from "./app-header.svelte";
|
||||
export { default as AppSidebar } from "./app-sidebar.svelte";
|
||||
export { default as DashboardLayout } from "./dashboard-layout.svelte";
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type BadgeVariant,
|
||||
badgeVariants,
|
||||
default as Badge,
|
||||
} from "./badge.svelte";
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts" module>
|
||||
import type {
|
||||
HTMLAnchorAttributes,
|
||||
HTMLButtonAttributes,
|
||||
} from "svelte/elements";
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
|
||||
outline:
|
||||
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from "./button.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
@@ -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="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<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="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -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<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
@@ -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="card-footer"
|
||||
class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<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="card-header"
|
||||
class={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -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="card-title"
|
||||
class={cn("leading-none font-semibold", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<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="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
apps/publisher-dashboard/src/lib/components/ui/card/index.ts
Normal file
25
apps/publisher-dashboard/src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "separator",
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,28 @@
|
||||
import Root from "./table.svelte";
|
||||
import Body from "./table-body.svelte";
|
||||
import Caption from "./table-caption.svelte";
|
||||
import Cell from "./table-cell.svelte";
|
||||
import Footer from "./table-footer.svelte";
|
||||
import Head from "./table-head.svelte";
|
||||
import Header from "./table-header.svelte";
|
||||
import Row from "./table-row.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Body,
|
||||
Caption,
|
||||
Cell,
|
||||
Footer,
|
||||
Head,
|
||||
Header,
|
||||
Row,
|
||||
//
|
||||
Root as Table,
|
||||
Body as TableBody,
|
||||
Caption as TableCaption,
|
||||
Cell as TableCell,
|
||||
Footer as TableFooter,
|
||||
Head as TableHead,
|
||||
Header as TableHeader,
|
||||
Row as TableRow,
|
||||
};
|
||||
@@ -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<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tbody
|
||||
bind:this={ref}
|
||||
data-slot="table-body"
|
||||
class={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
@@ -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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<caption
|
||||
bind:this={ref}
|
||||
data-slot="table-caption"
|
||||
class={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</caption>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTdAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTdAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<td
|
||||
bind:this={ref}
|
||||
data-slot="table-cell"
|
||||
class={cn(
|
||||
"bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</td>
|
||||
@@ -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<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tfoot
|
||||
bind:this={ref}
|
||||
data-slot="table-footer"
|
||||
class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tfoot>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLThAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLThAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<th
|
||||
bind:this={ref}
|
||||
data-slot="table-head"
|
||||
class={cn(
|
||||
"text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
@@ -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<HTMLTableSectionElement>> = $props();
|
||||
</script>
|
||||
|
||||
<thead
|
||||
bind:this={ref}
|
||||
data-slot="table-header"
|
||||
class={cn("[&_tr]:border-b", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
@@ -0,0 +1,23 @@
|
||||
<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<HTMLTableRowElement>> = $props();
|
||||
</script>
|
||||
|
||||
<tr
|
||||
bind:this={ref}
|
||||
data-slot="table-row"
|
||||
class={cn(
|
||||
"hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</tr>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTableAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLTableAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="table-container" class="relative w-full overflow-x-auto">
|
||||
<table
|
||||
bind:this={ref}
|
||||
data-slot="table"
|
||||
class={cn("w-full caption-bottom text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
</div>
|
||||
16
apps/publisher-dashboard/src/lib/components/ui/tabs/index.ts
Normal file
16
apps/publisher-dashboard/src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Root from "./tabs.svelte";
|
||||
import Content from "./tabs-content.svelte";
|
||||
import List from "./tabs-list.svelte";
|
||||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tabs-content"
|
||||
class={cn("flex-1 outline-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.List
|
||||
bind:ref
|
||||
data-slot="tabs-list"
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="tabs-trigger"
|
||||
class={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="tabs"
|
||||
class={cn("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
apps/publisher-dashboard/src/lib/utils.ts
Normal file
17
apps/publisher-dashboard/src/lib/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any }
|
||||
? Omit<T, "children">
|
||||
: T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
ref?: U | null;
|
||||
};
|
||||
14
apps/publisher-dashboard/src/routes/+layout.svelte
Normal file
14
apps/publisher-dashboard/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<main class="mx-auto max-w-7xl p-4">
|
||||
{@render children()}
|
||||
</main>
|
||||
3
apps/publisher-dashboard/src/routes/+layout.ts
Normal file
3
apps/publisher-dashboard/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Disable SSR for SPA mode
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
23
apps/publisher-dashboard/src/routes/+page.svelte
Normal file
23
apps/publisher-dashboard/src/routes/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { greet } from "@publisher/utils";
|
||||
|
||||
const message = greet("Publisher Dashboard");
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Publisher Dashboard</h1>
|
||||
<p class="text-muted-foreground">{message}</p>
|
||||
|
||||
<nav class="flex gap-4">
|
||||
<a
|
||||
href="/settings"
|
||||
class="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
68
apps/publisher-dashboard/src/routes/performance/+page.svelte
Normal file
68
apps/publisher-dashboard/src/routes/performance/+page.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import FrequentFilters from "$lib/components/dashboard/frequent-filters.svelte";
|
||||
import MetricCard from "$lib/components/dashboard/metric-card.svelte";
|
||||
import PeakTrafficChart from "$lib/components/dashboard/peak-traffic-chart.svelte";
|
||||
import PerformanceTable from "$lib/components/dashboard/performance-table.svelte";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
label: "Average daily revenue",
|
||||
value: "$64,252.02",
|
||||
change: 2.82,
|
||||
sparklineData: [30, 35, 32, 40, 45, 42, 50, 55, 52, 58, 62, 60],
|
||||
},
|
||||
{
|
||||
label: "Cost per mille",
|
||||
value: "$4.32",
|
||||
change: 0.82,
|
||||
sparklineData: [20, 22, 25, 23, 28, 30, 32, 35, 33, 38, 40, 42],
|
||||
},
|
||||
{
|
||||
label: "Revenue per mille",
|
||||
value: "$4.32",
|
||||
change: 0.82,
|
||||
sparklineData: [25, 28, 30, 32, 35, 38, 40, 42, 45, 48, 50, 52],
|
||||
},
|
||||
{
|
||||
label: "Invalid Traffic",
|
||||
value: "52.01%",
|
||||
change: -0.82,
|
||||
sparklineData: [60, 58, 55, 52, 50, 48, 45, 42, 40, 38, 35, 32],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SSP Network Performance - Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<PeakTrafficChart />
|
||||
<FrequentFilters />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
14
apps/publisher-dashboard/src/routes/settings/+page.svelte
Normal file
14
apps/publisher-dashboard/src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<svelte:head>
|
||||
<title>Settings - Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
|
||||
<p class="text-muted-foreground">Configure your publisher settings here.</p>
|
||||
|
||||
<nav>
|
||||
<a href="/" class="text-primary underline-offset-4 hover:underline">
|
||||
Back to Home
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
20
apps/publisher-dashboard/svelte.config.js
Normal file
20
apps/publisher-dashboard/svelte.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html", // SPA mode - all routes fall back to index.html
|
||||
}),
|
||||
paths: {
|
||||
base: "",
|
||||
},
|
||||
prerender: {
|
||||
handleHttpError: "warn",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
15
apps/publisher-dashboard/tsconfig.json
Normal file
15
apps/publisher-dashboard/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true
|
||||
}
|
||||
}
|
||||
7
apps/publisher-dashboard/vite.config.ts
Normal file
7
apps/publisher-dashboard/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
});
|
||||
Reference in New Issue
Block a user