Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import { Toaster } from '@/components/ui/sonner'
const theme = useTheme()
useHead({
htmlAttrs: { 'data-theme': theme },
})
</script>
<template>
<NuxtLoadingIndicator :height="2" :throttle="0" color="var(--primary)" />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<Toaster />
</template>
+814
View File
@@ -0,0 +1,814 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-vue/tailwind.css";
@custom-variant dark (&:is(.dark *));
@utility cn-font-heading {
font-family: var(--font-heading);
}
@theme inline {
--font-sans: var(--font-sans);
--font-weight-normal: 500;
--font-weight-medium: 600;
--font-weight-semibold: 700;
--font-weight-bold: 700;
--font-heading: var(--font-heading);
--font-logo: var(--font-logo);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-xs: calc(var(--radius) * 0.2);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) * 1.6);
--radius-3xl: calc(var(--radius) * 2.4);
}
:root, [data-theme="standard"] {
--font-sans: 'Figtree', sans-serif;
--font-heading: 'Manrope', sans-serif;
--font-logo: 'Archivo Black', sans-serif;
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.459 0.187 3.815);
--primary-foreground: oklch(0.971 0.014 343.198);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.459 0.187 3.815);
--accent-foreground: oklch(0.971 0.014 343.198);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.823 0.12 346.018);
--chart-2: oklch(0.656 0.241 354.308);
--chart-3: oklch(0.592 0.249 0.584);
--chart-4: oklch(0.525 0.223 3.958);
--chart-5: oklch(0.459 0.187 3.815);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.656 0.241 354.308);
--sidebar-primary-foreground: oklch(0.971 0.014 343.198);
--sidebar-accent: oklch(0.459 0.187 3.815);
--sidebar-accent-foreground: oklch(0.971 0.014 343.198);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
/* Discord-Theme (tweakcn, Dark-Variante) */
[data-theme="discord"] {
--font-sans: 'Noto Sans JP', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Noto Sans JP', ui-sans-serif, sans-serif, system-ui;
--radius: 0.625rem;
--background: oklch(0.3225 0.0107 278.2517);
--foreground: oklch(1 0 0);
--card: oklch(0.3502 0.0122 279.2835);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.3502 0.0122 279.2835);
--popover-foreground: oklch(1 0 0);
--primary: oklch(0.5774 0.2091 273.8504);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.3779 0.0118 285.8207);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.3066 0.0108 278.2166);
--muted-foreground: oklch(0.723 0.0087 278.582);
--accent: oklch(0.4074 0.0134 280.1154);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.8004 0.1141 23.185);
--border: oklch(0.369 0.0103 278.3345);
--input: oklch(0.3066 0.0108 278.2166);
--ring: oklch(0.5774 0.2091 273.8504);
--chart-1: oklch(0.8092 0.1122 345.1956);
--chart-2: oklch(0.8757 0.0979 83.6546);
--chart-3: oklch(0.9075 0.0946 165.054);
--chart-4: oklch(0.8406 0.0699 242.1914);
--chart-5: oklch(0.7865 0.1232 309.4396);
--sidebar: oklch(0.2981 0.0091 276.8201);
--sidebar-foreground: oklch(0.6876 0.0102 279.6082);
--sidebar-primary: oklch(0.3804 0.0102 278.3511);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.3309 0.0087 285.9137);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.3494 0.0087 276.903);
--sidebar-ring: oklch(0.7151 0.1523 235.2888);
}
/* Cyberpunk-Theme (tweakcn „赛博朋克", Dark-Variante):
Neon-Grün auf Schwarz, Magenta-Primary, eckig, Glow-Schatten */
[data-theme="cyberpunk"] {
--font-sans: 'Orbitron', sans-serif;
--font-heading: 'Orbitron', sans-serif;
--radius: 0rem;
--background: oklch(0.1149 0 0);
--foreground: oklch(0.8803 0.2083 157.334);
--card: oklch(0.1591 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0.1149 0 0);
--popover-foreground: oklch(0.8803 0.2083 157.334);
--primary: oklch(0.6083 0.2973 312.1096);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.7392 0.1576 235.3407);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.2178 0 0);
--muted-foreground: oklch(0.5693 0 0);
--accent: oklch(0.6354 0.2541 15.4582);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.6317 0.2544 21.7529);
--border: oklch(0.3211 0 0);
--input: oklch(0.1591 0 0);
--ring: oklch(0.6083 0.2973 312.1096);
--chart-1: oklch(0.6083 0.2973 312.1096);
--chart-2: oklch(0.8803 0.2083 157.334);
--chart-3: oklch(0.7392 0.1576 235.3407);
--chart-4: oklch(0.6354 0.2541 15.4582);
--chart-5: oklch(0.9616 0.213 112.0777);
--sidebar: oklch(0.0847 0 0);
--sidebar-foreground: oklch(0.6268 0 0);
--sidebar-primary: oklch(0.6083 0.2973 312.1096);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2178 0 0);
--sidebar-accent-foreground: oklch(0.8803 0.2083 157.334);
--sidebar-border: oklch(0.252 0 0);
--sidebar-ring: oklch(0.7392 0.1576 235.3407);
--shadow-2xs: 0 0 20px 4px hsl(340 100% 50% / 0.4);
--shadow-xs: 0 0 20px 4px hsl(340 100% 50% / 0.4);
--shadow-sm: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 1px 2px 3px hsl(340 100% 50% / 0.8);
--shadow: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 1px 2px 3px hsl(340 100% 50% / 0.8);
--shadow-md: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 2px 4px 3px hsl(340 100% 50% / 0.8);
--shadow-lg: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 4px 6px 3px hsl(340 100% 50% / 0.8);
--shadow-xl: 0 0 20px 4px hsl(340 100% 50% / 0.8), 0 8px 10px 3px hsl(340 100% 50% / 0.8);
--shadow-2xl: 0 0 20px 4px hsl(340 100% 50%);
}
[data-theme="cyberpunk"] body {
letter-spacing: 0.08em;
}
/* Spring-Theme (tweakcn „CuteSpringTheme", Dark-Variante):
Mauve/Pflaume-Hintergrund, Pink-Primary, Mint-Secondary, sehr rund */
[data-theme="spring"] {
--font-sans: 'Jua', sans-serif;
--font-heading: 'Jua', sans-serif;
--radius: 1.25rem;
--background: oklch(0.2482 0.0307 354.5794);
--foreground: oklch(0.9678 0.0166 3.6461);
--card: oklch(0.3001 0.0369 357.054);
--card-foreground: oklch(0.9678 0.0166 3.6461);
--popover: oklch(0.3001 0.0369 357.054);
--popover-foreground: oklch(0.9678 0.0166 3.6461);
--primary: oklch(0.7716 0.1387 9.6611);
--primary-foreground: oklch(0.2482 0.0307 354.5794);
--secondary: oklch(0.766 0.1179 145.295);
--secondary-foreground: oklch(0.2941 0.0509 145.0535);
--muted: oklch(0.3458 0.0388 356.0232);
--muted-foreground: oklch(0.8178 0.0293 355.5969);
--accent: oklch(0.8391 0.1615 84.3763);
--accent-foreground: oklch(0.2482 0.0307 354.5794);
--destructive: oklch(0.6875 0.142 21.4566);
--border: oklch(0.4074 0.043 356.7696);
--input: oklch(0.3001 0.0369 357.054);
--ring: oklch(0.7716 0.1387 9.6611);
--chart-1: oklch(0.7716 0.1387 9.6611);
--chart-2: oklch(0.766 0.1179 145.295);
--chart-3: oklch(0.8391 0.1615 84.3763);
--chart-4: oklch(0.6297 0.1321 299.3578);
--chart-5: oklch(0.7546 0.1512 38.2575);
--sidebar: oklch(0.2239 0.0279 355.4364);
--sidebar-foreground: oklch(0.9678 0.0166 3.6461);
--sidebar-primary: oklch(0.7716 0.1387 9.6611);
--sidebar-primary-foreground: oklch(0.2482 0.0307 354.5794);
--sidebar-accent: oklch(0.3001 0.0369 357.054);
--sidebar-accent-foreground: oklch(0.9678 0.0166 3.6461);
--sidebar-border: oklch(0.3458 0.0388 356.0232);
--sidebar-ring: oklch(0.7716 0.1387 9.6611);
}
[data-theme="spring"] body {
letter-spacing: 0.02em;
}
/* Terminal-Theme (tweakcn „iconic terminal", Dark-Variante):
Monospace, Teal-Phosphor auf Schwarz, eckig, keine Schatten */
[data-theme="terminal"] {
--font-sans: 'JetBrains Mono', monospace;
--font-heading: 'JetBrains Mono', monospace;
--radius: 0rem;
--background: oklch(0.1234 0.0134 174.8007);
--foreground: oklch(0.7678 0.1655 162.189);
--card: oklch(0.1577 0.018 172.3648);
--card-foreground: oklch(0.7678 0.1655 162.189);
--popover: oklch(0.1414 0.0154 174.4925);
--popover-foreground: oklch(0.7678 0.1655 162.189);
--primary: oklch(0.7678 0.1655 162.189);
--primary-foreground: oklch(0.1234 0.0134 174.8007);
--secondary: oklch(0.2393 0.0298 170.0849);
--secondary-foreground: oklch(0.7678 0.1655 162.189);
--muted: oklch(0.2123 0.0248 170.4461);
--muted-foreground: oklch(0.4814 0.0721 168.7206);
--accent: oklch(0.304 0.0414 169.4923);
--accent-foreground: oklch(0.8309 0.1796 162.0706);
--destructive: oklch(0.4344 0.1466 25.7809);
--border: oklch(0.4274 0.0886 163.5258);
--input: oklch(0.3519 0.0714 164.2596);
--ring: oklch(0.7678 0.1655 162.189);
--chart-1: oklch(0.7678 0.1655 162.189);
--chart-2: oklch(0.8052 0.1423 171.7621);
--chart-3: oklch(0.7935 0.133 163.1545);
--chart-4: oklch(0.6201 0.0963 179.7192);
--chart-5: oklch(0.4814 0.0721 168.7206);
--sidebar: oklch(0.0979 0.0106 174.8007);
--sidebar-foreground: oklch(0.7678 0.1655 162.189);
--sidebar-primary: oklch(0.7678 0.1655 162.189);
--sidebar-primary-foreground: oklch(0.1234 0.0134 174.8007);
--sidebar-accent: oklch(0.2393 0.0298 170.0849);
--sidebar-accent-foreground: oklch(0.8309 0.1796 162.0706);
--sidebar-border: oklch(0.3519 0.0714 164.2596);
--sidebar-ring: oklch(0.7678 0.1655 162.189);
--shadow-2xs: 0 0 0 0 transparent;
--shadow-xs: 0 0 0 0 transparent;
--shadow-sm: 0 0 0 0 transparent;
--shadow: 0 0 0 0 transparent;
--shadow-md: 0 0 0 0 transparent;
--shadow-lg: 0 0 0 0 transparent;
--shadow-xl: 0 0 0 0 transparent;
--shadow-2xl: 0 0 0 0 transparent;
}
[data-theme="terminal"] body {
letter-spacing: 0.05em;
}
/* Infoseer-Theme (tweakcn, Dark-Variante): schlichtes Monochrome,
weiße Primary-Buttons mit dunkler Schrift */
[data-theme="infoseer"] {
--font-sans: 'Fira Sans', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Fira Sans', ui-sans-serif, sans-serif, system-ui;
--radius: 0.625rem;
--background: oklch(0.2138 0.0019 286.2347);
--foreground: oklch(0.9851 0 0);
--card: oklch(0.2138 0.0019 286.2347);
--card-foreground: oklch(0.9851 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9851 0 0);
--primary: oklch(0.9219 0 0);
--primary-foreground: oklch(0.2138 0.0019 286.2347);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.709 0 0);
--accent: oklch(0.3715 0 0);
--accent-foreground: oklch(0.9851 0 0);
--destructive: oklch(0.709 0.1879 11.8353);
--border: oklch(0.2768 0 0);
--input: oklch(0.325 0 0);
--ring: oklch(0.5555 0 0);
--chart-1: oklch(0.8497 0.0888 233.6561);
--chart-2: oklch(0.6874 0.157 248.1539);
--chart-3: oklch(0.619 0.181 254.6741);
--chart-4: oklch(0.5528 0.1826 257.4254);
--chart-5: oklch(0.4684 0.1508 259.5323);
--sidebar: oklch(0.2138 0.0019 286.2347);
--sidebar-foreground: oklch(0.9851 0 0);
--sidebar-primary: oklch(0.5526 0.2035 259.283);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.2686 0 0);
--sidebar-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(0.2768 0 0);
--sidebar-ring: oklch(0.4386 0 0);
}
[data-theme="infoseer"] body {
letter-spacing: 0.025em;
}
/* Homebrew-Theme (tweakcn, Dark-Variante): Matrix-Grün auf Schwarz,
eckig, grüner Neon-Glow */
[data-theme="homebrew"] {
--font-sans: 'Inter', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'JetBrains Mono', ui-monospace, monospace;
--radius: 0rem;
--background: oklch(0 0 0);
--foreground: oklch(0.8686 0.2776 144.4661);
--card: oklch(0.2295 0.0624 145.0466);
--card-foreground: oklch(0.8686 0.2776 144.4661);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.8686 0.2776 144.4661);
--primary: oklch(0.8686 0.2776 144.4661);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.3053 0.1039 142.4953);
--secondary-foreground: oklch(0.8686 0.2776 144.4661);
--muted: oklch(0.1887 0.0642 142.4953);
--muted-foreground: oklch(0.7556 0.2021 146.5242);
--accent: oklch(0.3053 0.1039 142.4953);
--accent-foreground: oklch(0.8686 0.2776 144.4661);
--destructive: oklch(0.6786 0.2095 24.6583);
--border: oklch(0.5412 0.175 144.0774);
--input: oklch(0.1149 0 0);
--ring: oklch(0.8686 0.2776 144.4661);
--chart-1: oklch(0.8686 0.2776 144.4661);
--chart-2: oklch(0.5638 0.1872 143.245);
--chart-3: oklch(0.3053 0.1039 142.4953);
--chart-4: oklch(0.1179 0.0327 343.3438);
--chart-5: oklch(0.8686 0.2776 144.4661);
--sidebar: oklch(0.1149 0 0);
--sidebar-foreground: oklch(0.8686 0.2776 144.4661);
--sidebar-primary: oklch(0.8686 0.2776 144.4661);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.3053 0.1039 142.4953);
--sidebar-accent-foreground: oklch(0.8686 0.2776 144.4661);
--sidebar-border: oklch(0.5412 0.175 144.0774);
--sidebar-ring: oklch(0.8686 0.2776 144.4661);
--shadow-2xs: 0 0 15px 2px hsl(135 100% 50% / 0.2);
--shadow-xs: 0 0 15px 2px hsl(135 100% 50% / 0.2);
--shadow-sm: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 1px 2px 1px hsl(135 100% 50% / 0.4);
--shadow: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 1px 2px 1px hsl(135 100% 50% / 0.4);
--shadow-md: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 2px 4px 1px hsl(135 100% 50% / 0.4);
--shadow-lg: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 4px 6px 1px hsl(135 100% 50% / 0.4);
--shadow-xl: 0 0 15px 2px hsl(135 100% 50% / 0.4), 0 8px 10px 1px hsl(135 100% 50% / 0.4);
--shadow-2xl: 0 0 15px 2px hsl(135 100% 50%);
}
[data-theme="homebrew"] body {
letter-spacing: 0.05em;
}
/* Florense-Theme (tweakcn, Dark-Variante): reines Monochrome,
weiße Primary mit schwarzer Schrift, keine Schatten */
[data-theme="florense"] {
--font-sans: 'Geist', ui-sans-serif, sans-serif, system-ui;
--font-heading: 'Lora', ui-serif, serif;
--radius: 1rem;
--background: oklch(0.0969 0 0);
--foreground: oklch(0.9851 0 0);
--card: oklch(0 0 0);
--card-foreground: oklch(0.9851 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9851 0 0);
--primary: oklch(1 0 0);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2178 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2178 0 0);
--muted-foreground: oklch(0.683 0 0);
--accent: oklch(0.2686 0 0);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(1 0 0);
--border: oklch(0.1913 0 0);
--input: oklch(0.2686 0 0);
--ring: oklch(1 0 0);
--chart-1: oklch(1 0 0);
--chart-2: oklch(0.8452 0 0);
--chart-3: oklch(0.683 0 0);
--chart-4: oklch(0.5103 0 0);
--chart-5: oklch(0.3211 0 0);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(0.9851 0 0);
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.2178 0 0);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.2686 0 0);
--sidebar-ring: oklch(1 0 0);
--shadow-2xs: 0 0 0 0 transparent;
--shadow-xs: 0 0 0 0 transparent;
--shadow-sm: 0 0 0 0 transparent;
--shadow: 0 0 0 0 transparent;
--shadow-md: 0 0 0 0 transparent;
--shadow-lg: 0 0 0 0 transparent;
--shadow-xl: 0 0 0 0 transparent;
--shadow-2xl: 0 0 0 0 transparent;
}
/* Kawi-Green-Theme (tweakcn „high tech kawi green", Dark-Variante):
Schwarz mit Neon-Grün-Borders, roter Secondary, weiße Offset-Schatten */
[data-theme="kawi-green"] {
--font-sans: 'Inter', sans-serif;
--font-heading: 'JetBrains Mono', monospace;
--radius: 0rem;
--background: oklch(0 0 0);
--foreground: oklch(1 0 0);
--card: oklch(0 0 0);
--card-foreground: oklch(1 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(1 0 0);
--primary: oklch(0.8815 0.275 138.4872);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.628 0.2577 29.2339);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.1448 0 0);
--muted-foreground: oklch(1 0 0);
--accent: oklch(1 0 0);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.628 0.2577 29.2339);
--border: oklch(0.8815 0.275 138.4872);
--input: oklch(0.8815 0.275 138.4872);
--ring: oklch(0.8815 0.275 138.4872);
--chart-1: oklch(0.8815 0.275 138.4872);
--chart-2: oklch(0.628 0.2577 29.2339);
--chart-3: oklch(1 0 0);
--chart-4: oklch(0.8003 0.1821 151.711);
--chart-5: oklch(0.645 0.2154 16.4393);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.8815 0.275 138.4872);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.1776 0 0);
--sidebar-accent-foreground: oklch(0.8815 0.275 138.4872);
--sidebar-border: oklch(0.8815 0.275 138.4872);
--sidebar-ring: oklch(0.8815 0.275 138.4872);
--shadow-2xs: -4px 4px 0 0 hsl(0 0% 100% / 0.5);
--shadow-xs: -4px 4px 0 0 hsl(0 0% 100% / 0.5);
--shadow-sm: -4px 4px 0 0 hsl(0 0% 100%), -4px 1px 2px -1px hsl(0 0% 100%);
--shadow: -4px 4px 0 0 hsl(0 0% 100%), -4px 1px 2px -1px hsl(0 0% 100%);
--shadow-md: -4px 4px 0 0 hsl(0 0% 100%), -4px 2px 4px -1px hsl(0 0% 100%);
--shadow-lg: -4px 4px 0 0 hsl(0 0% 100%), -4px 4px 6px -1px hsl(0 0% 100%);
--shadow-xl: -4px 4px 0 0 hsl(0 0% 100%), -4px 8px 10px -1px hsl(0 0% 100%);
--shadow-2xl: -4px 4px 0 0 hsl(0 0% 100%);
}
[data-theme="kawi-green"] body {
letter-spacing: 0.05em;
}
/* Clo.nz-Theme (tweakcn, Dark-Variante): Slate-Dunkelblau,
Off-White-Primary, orangener Focus-Ring */
[data-theme="clonz"] {
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-heading: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--radius: 0.5rem;
--background: oklch(0.2184 0.0079 274.5602);
--foreground: oklch(0.9596 0.0131 82.4019);
--card: oklch(0.2614 0.0094 276.7359);
--card-foreground: oklch(0.9596 0.0131 82.4019);
--popover: oklch(0.2614 0.0094 276.7359);
--popover-foreground: oklch(0.9596 0.0131 82.4019);
--primary: oklch(0.9596 0.0131 82.4019);
--primary-foreground: oklch(0.2184 0.0079 274.5602);
--secondary: oklch(0.3225 0.0205 73.1712);
--secondary-foreground: oklch(0.9596 0.0131 82.4019);
--muted: oklch(0.3022 0.0091 276.8278);
--muted-foreground: oklch(0.7333 0.0115 280.4017);
--accent: oklch(0.3022 0.0091 276.8278);
--accent-foreground: oklch(0.9596 0.0131 82.4019);
--destructive: oklch(0.5231 0.1583 27.6707);
--border: oklch(0.3022 0.0091 276.8278);
--input: oklch(0.3022 0.0091 276.8278);
--ring: oklch(0.6711 0.1394 48.3137);
--chart-1: oklch(0.9596 0.0131 82.4019);
--chart-2: oklch(0.6711 0.1394 48.3137);
--chart-3: oklch(0.484 0.0665 184.1633);
--chart-4: oklch(0.5194 0.0174 277.5749);
--chart-5: oklch(0.7333 0.0115 280.4017);
--sidebar: oklch(0.2184 0.0079 274.5602);
--sidebar-foreground: oklch(0.9596 0.0131 82.4019);
--sidebar-primary: oklch(0.3022 0.0091 276.8278);
--sidebar-primary-foreground: oklch(0.9596 0.0131 82.4019);
--sidebar-accent: oklch(0.3022 0.0091 276.8278);
--sidebar-accent-foreground: oklch(0.9596 0.0131 82.4019);
--sidebar-border: oklch(0.3022 0.0091 276.8278);
--sidebar-ring: oklch(0.6711 0.1394 48.3137);
}
/* Clawmorphism-Theme (tweakcn, Dark-Variante): neutrales Grau,
markante 6px-Offset-Schatten */
[data-theme="clawmorphism"] {
--font-sans: 'Inter', sans-serif;
--font-heading: 'Inter', sans-serif;
--radius: 0.575rem;
--background: oklch(0.2972 0 0);
--foreground: oklch(0.7826 0 0);
--card: oklch(0.325 0 0);
--card-foreground: oklch(0.7826 0 0);
--popover: oklch(0.2603 0 0);
--popover-foreground: oklch(0.931 0 0);
--primary: oklch(0.8822 0 0);
--primary-foreground: oklch(0.2435 0 0);
--secondary: oklch(0.3092 0 0);
--secondary-foreground: oklch(0.937 0 0);
--muted: oklch(0.2435 0 0);
--muted-foreground: oklch(0.6993 0 0);
--accent: oklch(0.3329 0 0);
--accent-foreground: oklch(0.9672 0 0);
--destructive: oklch(0.6398 0.0838 195.2629);
--border: oklch(0.36 0 0);
--input: oklch(0.3753 0 0);
--ring: oklch(0.2603 0 0);
--chart-1: oklch(0.9249 0 0);
--chart-2: oklch(0.7921 0 0);
--chart-3: oklch(0.66 0 0);
--chart-4: oklch(0.5208 0 0);
--chart-5: oklch(0.4017 0 0);
--sidebar: oklch(0.2435 0 0);
--sidebar-foreground: oklch(0.931 0 0);
--sidebar-primary: oklch(0.8822 0 0);
--sidebar-primary-foreground: oklch(0.2435 0 0);
--sidebar-accent: oklch(0.2809 0 0);
--sidebar-accent-foreground: oklch(0.931 0 0);
--sidebar-border: oklch(0.3171 0 0);
--sidebar-ring: oklch(0.6401 0 0);
--shadow-2xs: 6px 6px 16px 0 hsl(0 0% 8% / 0.38);
--shadow-xs: 6px 6px 16px 0 hsl(0 0% 8% / 0.38);
--shadow-sm: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 1px 2px -1px hsl(0 0% 8% / 0.76);
--shadow: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 1px 2px -1px hsl(0 0% 8% / 0.76);
--shadow-md: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 2px 4px -1px hsl(0 0% 8% / 0.76);
--shadow-lg: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 4px 6px -1px hsl(0 0% 8% / 0.76);
--shadow-xl: 6px 6px 16px 0 hsl(0 0% 8% / 0.76), 6px 8px 10px -1px hsl(0 0% 8% / 0.76);
--shadow-2xl: 6px 6px 16px 0 hsl(0 0% 8%);
}
/* Realmorphism-Theme (tweakcn, Dark-Variante): dunkles Teal/Forest,
harte dunkelgrüne Offset-Schatten */
[data-theme="realmorphism"] {
--font-sans: 'Inter', sans-serif;
--font-heading: 'Inter', sans-serif;
--radius: 0.5rem;
--background: oklch(0.1276 0.0035 245.8087);
--foreground: oklch(0.9457 0.0091 161.3462);
--card: oklch(0.1712 0.0039 229.0605);
--card-foreground: oklch(0.9457 0.0091 161.3462);
--popover: oklch(0.1712 0.0039 229.0605);
--popover-foreground: oklch(0.9457 0.0091 161.3462);
--primary: oklch(0.4794 0.075 168.9054);
--primary-foreground: oklch(0.9457 0.0091 161.3462);
--secondary: oklch(0.2201 0.0095 159.2095);
--secondary-foreground: oklch(0.9457 0.0091 161.3462);
--muted: oklch(0.2201 0.0095 159.2095);
--muted-foreground: oklch(0.7341 0.0188 169.8711);
--accent: oklch(0.5955 0.0953 168.0699);
--accent-foreground: oklch(0.9457 0.0091 161.3462);
--destructive: oklch(0.4041 0.112 23.5585);
--border: oklch(0.317 0.0173 165.969);
--input: oklch(0.2201 0.0095 159.2095);
--ring: oklch(0.5955 0.0953 168.0699);
--chart-1: oklch(0.4794 0.075 168.9054);
--chart-2: oklch(0.5337 0.0845 74.0079);
--chart-3: oklch(0.9068 0.1567 156.0616);
--chart-4: oklch(0.7341 0.0188 169.8711);
--chart-5: oklch(0.879 0.1534 91.6054);
--sidebar: oklch(0.1276 0.0035 245.8087);
--sidebar-foreground: oklch(0.9457 0.0091 161.3462);
--sidebar-primary: oklch(0.4794 0.075 168.9054);
--sidebar-primary-foreground: oklch(0.9457 0.0091 161.3462);
--sidebar-accent: oklch(0.2201 0.0095 159.2095);
--sidebar-accent-foreground: oklch(0.9457 0.0091 161.3462);
--sidebar-border: oklch(0.317 0.0173 165.969);
--sidebar-ring: oklch(0.5955 0.0953 168.0699);
--shadow-2xs: 2px 2px 0 1px hsl(160.6 43.6% 29.2% / 0.5);
--shadow-xs: 2px 2px 0 1px hsl(160.6 43.6% 29.2% / 0.5);
--shadow-sm: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 1px 2px 0 hsl(160.6 43.6% 29.2%);
--shadow: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 1px 2px 0 hsl(160.6 43.6% 29.2%);
--shadow-md: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 2px 4px 0 hsl(160.6 43.6% 29.2%);
--shadow-lg: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 4px 6px 0 hsl(160.6 43.6% 29.2%);
--shadow-xl: 2px 2px 0 1px hsl(160.6 43.6% 29.2%), 2px 8px 10px 0 hsl(160.6 43.6% 29.2%);
--shadow-2xl: 2px 2px 0 1px hsl(160.6 43.6% 29.2%);
}
/* Amber-Theme (tweakcn „my amber theme", Dark-Variante):
Amber-Phosphor auf Schwarz, eckig, Amber-Glow, Retro-Font Workbench */
[data-theme="amber"] {
--font-sans: 'Workbench', ui-monospace, monospace;
--font-heading: 'Workbench', ui-monospace, monospace;
--radius: 0rem;
--background: oklch(0 0 0);
--foreground: oklch(0.8403 0.1724 84.0757);
--card: oklch(0.1149 0 0);
--card-foreground: oklch(0.8403 0.1724 84.0757);
--popover: oklch(0.1149 0 0);
--popover-foreground: oklch(0.8403 0.1724 84.0757);
--primary: oklch(0.8403 0.1724 84.0757);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.266 0.055 80.8676);
--secondary-foreground: oklch(0.8403 0.1724 84.0757);
--muted: oklch(0.1845 0.0378 85.8185);
--muted-foreground: oklch(0.5155 0.107 79.2295);
--accent: oklch(0.8403 0.1724 84.0757);
--accent-foreground: oklch(0 0 0);
--destructive: oklch(0.2428 0.0996 29.2339);
--border: oklch(0.8403 0.1724 84.0757);
--input: oklch(0.8403 0.1724 84.0757);
--ring: oklch(0.8403 0.1724 84.0757);
--chart-1: oklch(0.8403 0.1724 84.0757);
--chart-2: oklch(0.7773 0.1595 84.1047);
--chart-3: oklch(0.7119 0.146 84.6103);
--chart-4: oklch(0.6462 0.1325 84.7159);
--chart-5: oklch(0.5775 0.1183 85.4662);
--sidebar: oklch(0 0 0);
--sidebar-foreground: oklch(0.8403 0.1724 84.0757);
--sidebar-primary: oklch(0.8403 0.1724 84.0757);
--sidebar-primary-foreground: oklch(0 0 0);
--sidebar-accent: oklch(0.266 0.055 80.8676);
--sidebar-accent-foreground: oklch(0.8403 0.1724 84.0757);
--sidebar-border: oklch(0.8403 0.1724 84.0757);
--sidebar-ring: oklch(0.8403 0.1724 84.0757);
--shadow-2xs: 0 0 4px 0 hsl(45 100% 50% / 0.1);
--shadow-xs: 0 0 4px 0 hsl(45 100% 50% / 0.1);
--shadow-sm: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 1px 2px -1px hsl(45 100% 50% / 0.2);
--shadow: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 1px 2px -1px hsl(45 100% 50% / 0.2);
--shadow-md: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 2px 4px -1px hsl(45 100% 50% / 0.2);
--shadow-lg: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 4px 6px -1px hsl(45 100% 50% / 0.2);
--shadow-xl: 0 0 4px 0 hsl(45 100% 50% / 0.2), 0 8px 10px -1px hsl(45 100% 50% / 0.2);
--shadow-2xl: 0 0 4px 0 hsl(45 100% 50% / 0.5);
}
[data-theme="amber"] body {
letter-spacing: 0.05em;
}
/* Neo-Brutalismus: helle Pastell-Flächen, schwarze 2px-Borders,
harte Offset-Schatten, keine Rundungen */
[data-theme="neo"] {
--font-sans: 'Space Grotesk', sans-serif;
--font-heading: 'Archivo Black', sans-serif;
--radius: 0rem;
--background: oklch(0.96 0.045 95);
--foreground: oklch(0.22 0.02 60);
--card: oklch(0.99 0.01 95);
--card-foreground: oklch(0.22 0.02 60);
--popover: oklch(0.99 0.01 95);
--popover-foreground: oklch(0.22 0.02 60);
--primary: oklch(0.86 0.17 95);
--primary-foreground: oklch(0.22 0.02 60);
--secondary: oklch(0.9 0.06 95);
--secondary-foreground: oklch(0.22 0.02 60);
--muted: oklch(0.92 0.04 95);
--muted-foreground: oklch(0.45 0.03 60);
--accent: oklch(0.75 0.19 350);
--accent-foreground: oklch(0.22 0.02 60);
--destructive: oklch(0.6 0.24 25);
--border: oklch(0.22 0.02 60);
--input: oklch(0.22 0.02 60);
--ring: oklch(0.22 0.02 60);
--sidebar: oklch(0.99 0.01 95);
--sidebar-foreground: oklch(0.22 0.02 60);
--sidebar-primary: oklch(0.86 0.17 95);
--sidebar-primary-foreground: oklch(0.22 0.02 60);
--sidebar-accent: oklch(0.9 0.06 95);
--sidebar-accent-foreground: oklch(0.22 0.02 60);
--sidebar-border: oklch(0.22 0.02 60);
--sidebar-ring: oklch(0.22 0.02 60);
}
[data-theme="neo-pink"] {
--font-sans: 'Space Grotesk', sans-serif;
--font-heading: 'Archivo Black', sans-serif;
--radius: 0rem;
--background: oklch(0.93 0.07 355);
--foreground: oklch(0.25 0.05 340);
--card: oklch(0.97 0.04 355);
--card-foreground: oklch(0.25 0.05 340);
--popover: oklch(0.97 0.04 355);
--popover-foreground: oklch(0.25 0.05 340);
--primary: oklch(0.85 0.15 200);
--primary-foreground: oklch(0.25 0.05 340);
--secondary: oklch(0.88 0.08 350);
--secondary-foreground: oklch(0.25 0.05 340);
--muted: oklch(0.9 0.06 355);
--muted-foreground: oklch(0.48 0.07 340);
--accent: oklch(0.88 0.17 95);
--accent-foreground: oklch(0.25 0.05 340);
--destructive: oklch(0.58 0.24 25);
--border: oklch(0.25 0.05 340);
--input: oklch(0.25 0.05 340);
--ring: oklch(0.25 0.05 340);
--sidebar: oklch(0.97 0.04 355);
--sidebar-foreground: oklch(0.25 0.05 340);
--sidebar-primary: oklch(0.85 0.15 200);
--sidebar-primary-foreground: oklch(0.25 0.05 340);
--sidebar-accent: oklch(0.88 0.08 350);
--sidebar-accent-foreground: oklch(0.25 0.05 340);
--sidebar-border: oklch(0.25 0.05 340);
--sidebar-ring: oklch(0.25 0.05 340);
}
[data-theme="neo-mint"] {
--font-sans: 'Space Grotesk', sans-serif;
--font-heading: 'Archivo Black', sans-serif;
--radius: 0rem;
--background: oklch(0.93 0.08 160);
--foreground: oklch(0.25 0.05 200);
--card: oklch(0.97 0.04 160);
--card-foreground: oklch(0.25 0.05 200);
--popover: oklch(0.97 0.04 160);
--popover-foreground: oklch(0.25 0.05 200);
--primary: oklch(0.68 0.2 305);
--primary-foreground: oklch(0.98 0.02 300);
--secondary: oklch(0.88 0.08 165);
--secondary-foreground: oklch(0.25 0.05 200);
--muted: oklch(0.89 0.07 160);
--muted-foreground: oklch(0.45 0.06 200);
--accent: oklch(0.8 0.18 60);
--accent-foreground: oklch(0.25 0.05 200);
--destructive: oklch(0.58 0.24 25);
--border: oklch(0.25 0.05 200);
--input: oklch(0.25 0.05 200);
--ring: oklch(0.25 0.05 200);
--sidebar: oklch(0.97 0.04 160);
--sidebar-foreground: oklch(0.25 0.05 200);
--sidebar-primary: oklch(0.68 0.2 305);
--sidebar-primary-foreground: oklch(0.98 0.02 300);
--sidebar-accent: oklch(0.88 0.08 165);
--sidebar-accent-foreground: oklch(0.25 0.05 200);
--sidebar-border: oklch(0.25 0.05 200);
--sidebar-ring: oklch(0.25 0.05 200);
}
/* Brutalist-Regeln für alle neo-* Themes:
2px schwarze Borders + harter 4px-Offset-Schatten auf Karten-Elementen,
Buttons mit „reingedrückt"-Effekt beim Klick */
[data-theme^="neo"] .border,
[data-theme^="neo"] [data-slot="button"],
[data-theme^="neo"] .upload-tile-media {
border-width: 2px;
border-style: solid;
border-color: var(--border);
box-shadow: 4px 4px 0 0 var(--border);
}
[data-theme^="neo"] [data-slot="button"] {
transition: translate 0.1s ease, box-shadow 0.1s ease;
}
[data-theme^="neo"] [data-slot="button"]:hover {
translate: -1px -1px;
box-shadow: 5px 5px 0 0 var(--border);
}
[data-theme^="neo"] [data-slot="button"]:active {
translate: 2px 2px;
box-shadow: 1px 1px 0 0 var(--border);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply font-sans;
font-weight: 500;
letter-spacing: -0.01em;
}
h1, h2, h3, h4 {
letter-spacing: -0.02em;
}
}
/* View Transition für Upload-Media:
Das Bild morpht geschmeidig vom Thumbnail auf Thread-Größe.
Old/New nutzen den synchronen Standard-Crossfade (kein Blinken),
nur Dauer und Easing werden angeglichen. */
::view-transition-group(upload-media) {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition-old(upload-media),
::view-transition-new(upload-media) {
animation-duration: 380ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
/* Phosphor-Glyphen haben mehr Innenrand im ViewBox als Lucide —
leicht hochskalieren, damit sie bei gleicher size-* gleich groß wirken. */
svg.iconify--ph {
transform: scale(1.15);
}
+130
View File
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { useMediaControls } from '@vueuse/core'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { formatFileSize } from '~~/shared/utils/attachments'
import type { AttachmentAudioMeta } from '~~/shared/utils/attachments'
const props = defineProps<{
src: string
name: string
size?: number | null
cover?: string | null
meta?: AttachmentAudioMeta | null
}>()
const open = defineModel<boolean>('open', { required: true })
const audioRef = ref<HTMLAudioElement>()
const { playing, currentTime, duration, volume, muted } = useMediaControls(audioRef)
const metaLine = computed(() => {
const m = props.meta
if (!m) return null
const parts = [m.artist, m.title].filter(Boolean)
return parts.length ? parts.join(' – ') : (m.album || null)
})
// Die Komponente wird per v-if nur gemountet, wenn der Dialog offen ist
onMounted(async () => {
await nextTick()
playing.value = true
})
watch(open, (isOpen) => {
if (!isOpen) playing.value = false
})
function toggle() {
playing.value = !playing.value
}
function seek(event: Event) {
currentTime.value = Number((event.target as HTMLInputElement).value)
}
function formatTime(sec: number) {
if (!Number.isFinite(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent>
<DialogHeader>
<DialogTitle class="flex min-w-0 items-center gap-2 pr-10">
<Icon name="ph:file-audio-fill" class="size-4 shrink-0" />
<span class="min-w-0 truncate">{{ name }}</span>
</DialogTitle>
<DialogDescription v-if="metaLine" class="truncate">
{{ metaLine }}
</DialogDescription>
</DialogHeader>
<img
v-if="cover"
:src="cover"
:alt="name"
class="max-h-56 w-full rounded-md border bg-muted object-contain"
>
<audio ref="audioRef" :src="src" preload="metadata" class="hidden" />
<div class="flex min-w-0 items-center gap-2.5 rounded-md border bg-muted/50 px-3 py-2">
<button
type="button"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
:aria-label="playing ? 'Pause' : 'Abspielen'"
@click="toggle"
>
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
<Icon name="ph:play-fill" v-else class="size-4" />
</button>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(currentTime) }}</span>
<input
type="range"
min="0"
:max="Number.isFinite(duration) ? duration : 0"
step="0.1"
:value="currentTime"
aria-label="Wiedergabeposition"
class="min-w-0 flex-1 cursor-pointer accent-primary"
@input="seek"
>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(duration) }}</span>
</div>
<div class="flex min-w-0 items-center gap-2">
<button
type="button"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
:aria-label="muted ? 'Ton an' : 'Stumm'"
@click="muted = !muted"
>
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-4" />
<Icon name="ph:speaker-high-fill" v-else class="size-4" />
</button>
<input
v-model.number="volume"
type="range"
min="0"
max="1"
step="0.05"
aria-label="Lautstärke"
class="w-16 min-w-0 cursor-pointer accent-primary sm:w-20"
:class="muted && 'opacity-40'"
>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ formatFileSize(size) }}</span>
<Button variant="outline" size="sm" class="shrink-0" as-child>
<a :href="src" :download="name">
<Icon name="ph:download-fill" class="size-4" />
Herunterladen
</a>
</Button>
</div>
</DialogContent>
</Dialog>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command'
const { open } = useCommandPalette()
const { user, can } = useAuth()
useEventListener('keydown', (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
open.value = !open.value
}
})
async function go(to: string) {
open.value = false
await navigateTo(to)
}
</script>
<template>
<CommandDialog
v-model:open="open"
title="Schnellsuche"
description="Seiten, Tags oder Uploader suchen"
class="rounded-md!"
>
<CommandInput placeholder="Seiten, Tags oder Uploader suchen…" />
<CommandList>
<CommandEmpty>Keine Ergebnisse.</CommandEmpty>
<CommandGroup heading="Seiten">
<CommandItem value="Startseite" class="gap-2" @select="go('/')">
<Icon name="ph:house-fill" class="size-4 text-muted-foreground" />
Startseite
<CommandShortcut>/</CommandShortcut>
</CommandItem>
<CommandItem value="Benutzer" class="gap-2" @select="go('/users')">
<Icon name="ph:users-fill" class="size-4 text-muted-foreground" />
Benutzer
<CommandShortcut>/users</CommandShortcut>
</CommandItem>
<CommandItem v-if="user" value="Mein Profil" class="gap-2" @select="go(`/user/${user.id}`)">
<Icon name="ph:user-fill" class="size-4 text-muted-foreground" />
Mein Profil
</CommandItem>
<CommandItem v-if="user" value="Einstellungen" class="gap-2" @select="go('/settings')">
<Icon name="ph:gear-fill" class="size-4 text-muted-foreground" />
Einstellungen
</CommandItem>
<CommandItem v-if="can('admin.access')" value="Admin-Bereich" class="gap-2" @select="go('/admin')">
<Icon name="ph:shield-fill" class="size-4 text-muted-foreground" />
Admin-Bereich
</CommandItem>
<CommandItem v-if="!user" value="Login" class="gap-2" @select="go('/login')">
<Icon name="ph:sign-in-fill" class="size-4 text-muted-foreground" />
Login
</CommandItem>
<CommandItem v-if="!user" value="Registrieren" class="gap-2" @select="go('/register')">
<Icon name="ph:user-plus-fill" class="size-4 text-muted-foreground" />
Registrieren
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandPaletteResults @select="go" />
</CommandList>
</CommandDialog>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useCommand } from '@/components/ui/command'
import { CommandGroup, CommandItem } from '@/components/ui/command'
interface TagResult { name: string, count: number }
interface ListedUser { id: number, name: string, color: string | null, avatarUrl: string | null }
const emit = defineEmits<{ select: [to: string] }>()
const { filterState } = useCommand()
const tags = ref<TagResult[]>([])
const users = ref<ListedUser[]>([])
let tagTimer: ReturnType<typeof setTimeout> | undefined
watch(() => filterState.search, (q) => {
clearTimeout(tagTimer)
tagTimer = setTimeout(async () => {
try {
tags.value = await $fetch<TagResult[]>('/api/tags', { query: { q } })
}
catch {
tags.value = []
}
}, 150)
}, { immediate: true })
onMounted(async () => {
try {
users.value = await $fetch<ListedUser[]>('/api/users')
}
catch {
users.value = []
}
})
</script>
<template>
<CommandGroup v-if="tags.length" heading="Tags">
<CommandItem
v-for="t in tags"
:key="t.name"
:value="t.name"
class="gap-2"
@select="emit('select', `/?tag=${encodeURIComponent(t.name)}`)"
>
<Icon name="ph:tag-fill" class="size-4 text-muted-foreground" />
{{ t.name }}
<span class="ml-auto text-xs text-muted-foreground">{{ t.count }}</span>
</CommandItem>
</CommandGroup>
<CommandGroup heading="Uploader">
<CommandItem
v-for="u in users"
:key="u.id"
:value="u.name"
class="gap-2"
@select="emit('select', `/user/${u.id}`)"
>
<NuxtImg
v-if="u.avatarUrl"
:src="u.avatarUrl"
:alt="u.name"
width="20"
height="20"
fit="cover"
class="size-5 rounded-sm"
/>
<span
v-else
class="flex size-5 items-center justify-center rounded-sm text-[10px] font-medium"
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ u.name.slice(0, 2).toUpperCase() }}</span>
<span :style="u.color ? { color: u.color } : undefined">{{ u.name }}</span>
</CommandItem>
</CommandGroup>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import AudioPlayerDialog from '@/components/AudioPlayerDialog.vue'
import ImageLightbox from '@/components/ImageLightbox.vue'
import VoiceMessagePlayer from '@/components/VoiceMessagePlayer.vue'
import { Separator } from '@/components/ui/separator'
import { formatFileSize, isAudioAttachment, isImageAttachment, isVoiceAttachment } from '~~/shared/utils/attachments'
import type { CommentAttachment } from '~~/shared/utils/attachments'
const props = defineProps<{
attachments: CommentAttachment[]
compact?: boolean
}>()
const voices = computed(() => props.attachments.filter(isVoiceAttachment))
const images = computed(() => props.attachments.filter(a => isImageAttachment(a) && !isVoiceAttachment(a)))
const audios = computed(() => props.attachments.filter(a => isAudioAttachment(a) && !isImageAttachment(a) && !isVoiceAttachment(a)))
const files = computed(() => props.attachments.filter(a => !isImageAttachment(a) && !isAudioAttachment(a) && !isVoiceAttachment(a)))
const hasFileAttachments = computed(() => images.value.length + audios.value.length + files.value.length > 0)
const lightboxSrc = ref<string | null>(null)
const lightboxAlt = ref('')
const audioAtt = ref<CommentAttachment | null>(null)
function fileIcon(a: CommentAttachment) {
const mime = a.mimeType ?? ''
const name = a.name.toLowerCase()
if (mime.startsWith('video/')) return 'ph:file-video-fill'
if (mime === 'application/pdf' || /\.(txt|md|pdf|docx?|odt)$/.test(name)) return 'ph:file-text-fill'
if (/\.(zip|rar|7z|tar|gz|bz2|xz)$/.test(name) || /zip|compressed|x-tar|gzip/.test(mime)) return 'ph:file-archive-fill'
return 'ph:file-fill'
}
</script>
<template>
<!-- Kompakt-Modus (Sidebar): nur ein Hinweis-Chip -->
<span
v-if="compact && attachments.length"
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
>
<Icon name="ph:folder-plus-fill" class="size-3.5" />
{{ attachments.length }}
</span>
<div v-else-if="attachments.length" class="mt-2.5 space-y-2.5">
<!-- Sprachnachrichten gehören direkt zum Kommentar, nicht in den Anhang-Bereich -->
<div v-if="voices.length" class="flex flex-col gap-1.5">
<VoiceMessagePlayer
v-for="a in voices"
:key="a.id"
:src="a.url"
:name="a.name"
/>
</div>
<template v-if="hasFileAttachments">
<div class="mb-1.5 flex items-center gap-2">
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Anhänge</span>
<Separator class="flex-1" />
</div>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="a in images"
:key="a.id"
type="button"
class="group relative size-16 cursor-pointer overflow-hidden rounded-md border bg-muted transition-colors hover:border-primary"
:title="a.name"
@click.stop="lightboxSrc = a.url; lightboxAlt = a.name"
>
<NuxtImg
:src="a.url"
:alt="a.name"
width="64"
height="64"
fit="cover"
loading="lazy"
class="size-full object-cover"
/>
</button>
<button
v-for="a in audios"
:key="a.id"
type="button"
class="flex max-w-48 cursor-pointer items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
:title="`${a.name} abspielen`"
@click.stop="audioAtt = a"
>
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="size-5 shrink-0 rounded-sm object-cover">
<Icon name="ph:file-audio-fill" v-else class="size-3.5 shrink-0 text-muted-foreground" />
<span class="truncate">{{ a.name }}</span>
<span v-if="formatFileSize(a.size)" class="shrink-0 text-muted-foreground">{{ formatFileSize(a.size) }}</span>
<Icon name="ph:play-fill" class="size-3.5 shrink-0 text-muted-foreground" />
</button>
<a
v-for="a in files"
:key="a.id"
:href="a.url"
:download="a.name"
class="flex max-w-48 items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
:title="a.name"
@click.stop
>
<Icon :name="fileIcon(a)" class="size-3.5 shrink-0 text-muted-foreground" />
<span class="truncate">{{ a.name }}</span>
<span v-if="formatFileSize(a.size)" class="shrink-0 text-muted-foreground">{{ formatFileSize(a.size) }}</span>
<Icon name="ph:download-fill" class="size-3.5 shrink-0 text-muted-foreground" />
</a>
<ImageLightbox
v-if="lightboxSrc"
:src="lightboxSrc"
:alt="lightboxAlt"
@close="lightboxSrc = null"
/>
<AudioPlayerDialog
v-if="audioAtt"
:open="true"
:src="audioAtt.url"
:name="audioAtt.name"
:size="audioAtt.size"
:cover="audioAtt.cover"
:meta="audioAtt.meta"
@update:open="audioAtt = null"
/>
</div>
</template>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { mentionParts } from '~~/shared/utils/mentions'
import { getYoutubeId, youtubeEmbedUrl } from '~~/shared/utils/youtube'
import { getTwitchTarget, serializeTwitchTarget, twitchEmbedUrl } from '~~/shared/utils/twitch'
interface Part {
type: 'text' | 'link' | 'youtube' | 'twitch' | 'image'
text: string
href?: string
videoId?: string
twitchTarget?: string
}
const props = defineProps<{
content: string
compact?: boolean
}>()
// Twitch-Embeds brauchen den Hostname als parent-Parameter
const requestHost = useRequestURL().hostname
const URL_RE = /https?:\/\/[^\s<>"']+/g
const TRAILING_PUNCT = /[.,;:!?)\]]+$/
const IMAGE_EXT = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i
function isImageUrl(href: string): boolean {
try {
return IMAGE_EXT.test(new URL(href).pathname)
}
catch {
return false
}
}
function hostnameFor(part: Part): string {
const raw = part.href ?? part.text
try {
return new URL(raw).hostname.replace(/^www\./, '')
}
catch {
return raw
}
}
const { data: mentionUsers } = useMentionUsers()
const parts = computed<Part[]>(() => {
const result: Part[] = []
let last = 0
for (const match of props.content.matchAll(URL_RE)) {
const raw = match[0]
const href = raw.replace(TRAILING_PUNCT, '')
const trailing = raw.slice(href.length)
if (match.index > last) {
result.push({ type: 'text', text: props.content.slice(last, match.index) })
}
const videoId = getYoutubeId(href)
const twitchTarget = videoId ? null : serializeTwitchTarget(getTwitchTarget(href))
if (videoId) {
result.push({ type: 'youtube', text: href, videoId })
}
else if (twitchTarget) {
result.push({ type: 'twitch', text: href, twitchTarget })
}
else if (isImageUrl(href)) {
result.push({ type: 'image', text: href, href })
}
else {
result.push({ type: 'link', text: href, href })
}
if (trailing) {
result.push({ type: 'text', text: trailing })
}
last = match.index + raw.length
}
if (last < props.content.length) {
result.push({ type: 'text', text: props.content.slice(last) })
}
return result
})
</script>
<template>
<div class="whitespace-pre-wrap break-words text-sm">
<template v-for="(part, index) in parts" :key="index">
<template v-if="part.type === 'text'">
<template v-for="(segment, segmentIndex) in mentionParts(part.text, mentionUsers)" :key="segmentIndex">
<UserHoverCard v-if="segment.user" :user-id="segment.user.id" :user-name="segment.user.name" :user-color="segment.user.color">
<NuxtLink :to="`/user/${segment.user.id}`" class="rounded-sm bg-accent px-0.5 font-medium text-accent-foreground hover:underline" @click.stop>{{ segment.text }}</NuxtLink>
</UserHoverCard>
<template v-else>{{ segment.text }}</template>
</template>
</template>
<!-- Kompakt-Modus (Sidebar): Icon + Hostname statt Embed/Card -->
<span
v-else-if="compact && part.type !== 'text'"
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
>
<Icon v-if="part.type === 'youtube'" name="simple-icons:youtube" class="size-3.5" />
<Icon v-else-if="part.type === 'twitch'" name="simple-icons:twitch" class="size-3.5" />
<Icon name="ph:image-fill" v-else-if="part.type === 'image'" class="size-3.5" />
<Icon name="ph:link-fill" v-else class="size-3.5" />
{{ hostnameFor(part) }}
</span>
<template v-else-if="!compact">
<LinkPreviewCard
v-if="part.type === 'link'"
:url="part.href!"
/>
<div v-else-if="part.type === 'youtube'" class="mt-2 aspect-video overflow-hidden rounded-md">
<iframe
:src="youtubeEmbedUrl(part.videoId!)"
:title="part.text"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="h-full w-full"
/>
</div>
<div v-else-if="part.type === 'twitch'" class="mt-2 aspect-video overflow-hidden rounded-md">
<iframe
:src="twitchEmbedUrl(part.twitchTarget!, requestHost) ?? ''"
:title="part.text"
loading="lazy"
allowfullscreen
class="h-full w-full"
/>
</div>
<a
v-else-if="part.type === 'image'"
:href="part.href"
target="_blank"
rel="noopener noreferrer"
class="mt-2 block"
>
<NuxtImg
:src="part.href"
:alt="part.text"
loading="lazy"
class="max-h-60 rounded-md"
/>
</a>
<template v-else>{{ part.text }}</template>
</template>
<template v-else>{{ part.text }}</template>
</template>
</div>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const props = defineProps<{ commentId: number }>()
const { toggle } = useCommentReactions()
const open = ref(false)
const busy = ref(false)
async function pick(emoji: string) {
if (busy.value) return
busy.value = true
open.value = false
try {
await toggle(props.commentId, emoji)
}
catch {
toast.error('Reaktion fehlgeschlagen')
}
finally {
busy.value = false
}
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
class="cursor-pointer self-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Reaktion hinzufügen"
>
<Icon name="ph:smiley-fill" class="size-5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-auto p-2">
<div class="grid grid-cols-8 gap-1">
<button
v-for="e in REACTION_EMOJIS"
:key="e"
type="button"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-accent"
@click="pick(e)"
>
{{ e }}
</button>
</div>
</PopoverContent>
</Popover>
</template>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{ commentId: number }>()
const { user } = useAuth()
const { reactionsFor, toggle } = useCommentReactions()
const busy = ref(false)
const pending = ref<string | null>(null)
const reactions = computed(() => reactionsFor(props.commentId))
const BURST_LINES = Array.from({ length: 12 }, (_, i) => ({
angle: i * 30,
long: i % 2 === 0,
}))
const burstEmoji = ref<string | null>(null)
const burstNonce = ref(0)
async function pick(emoji: string) {
if (!user.value) {
toast.error('Melde dich an, um zu reagieren.')
return
}
if (busy.value) return
busy.value = true
pending.value = emoji
burstEmoji.value = emoji
burstNonce.value++
try {
await toggle(props.commentId, emoji)
}
catch {
toast.error('Reaktion fehlgeschlagen')
}
finally {
busy.value = false
pending.value = null
}
}
</script>
<template>
<div v-if="reactions.length" class="mt-3.5 flex flex-wrap items-center gap-2">
<Tooltip v-for="r in reactions" :key="r.emoji">
<TooltipTrigger as-child>
<button
type="button"
class="relative flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1 transition-colors"
:class="cn(
r.me
? 'border-foreground/40 bg-secondary hover:border-foreground/60'
: 'border-border bg-muted/40 hover:border-foreground/30',
pending === r.emoji && 'animate-pulse',
)"
@click="pick(r.emoji)"
>
<span v-if="burstEmoji === r.emoji" :key="burstNonce" class="pointer-events-none absolute inset-0" aria-hidden="true">
<i
v-for="l in BURST_LINES"
:key="l.angle"
class="burst-line"
:style="{ '--a': `${l.angle}deg`, '--h': l.long ? '9px' : '6px', '--d': l.long ? '-26px' : '-19px' }"
/>
</span>
<span :key="burstNonce" class="text-xl leading-none" :class="burstEmoji === r.emoji && 'emoji-pop'">{{ r.emoji }}</span>
<span class="text-sm font-medium tabular-nums" :class="r.me ? 'text-foreground' : 'text-muted-foreground'">{{ r.count }}</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ r.names.join(', ') }}</TooltipContent>
</Tooltip>
</div>
</template>
<style scoped>
.burst-line {
position: absolute;
left: 50%;
top: 50%;
width: 3px;
height: var(--h);
margin-left: -1.5px;
margin-top: -3px;
border-radius: 1.5px;
background: var(--color-primary);
opacity: 0;
}
.emoji-pop {
display: inline-block;
}
@media (prefers-reduced-motion: no-preference) {
.burst-line {
animation: burst-line 0.55s cubic-bezier(0.15, 0.85, 0.35, 1) forwards;
}
.emoji-pop {
animation: emoji-pop 0.3s ease-out;
}
}
@keyframes burst-line {
0% {
opacity: 1;
transform: rotate(var(--a)) translateY(-4px) scaleY(0.3);
}
30% {
opacity: 1;
transform: rotate(var(--a)) translateY(calc(var(--d) * 0.7)) scaleY(1.15);
}
100% {
opacity: 0;
transform: rotate(var(--a)) translateY(var(--d)) scaleY(0.5);
}
}
@keyframes emoji-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
</style>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import {
Sidebar,
SidebarContent,
} from '@/components/ui/sidebar'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import type { CommentAttachment } from '~~/shared/utils/attachments'
interface LatestComment {
id: number
content: string
createdAt: string
userName: string
userId: number
userColor: string | null
userAvatarUrl: string | null
uploadId: string
attachments?: CommentAttachment[]
}
const { data: comments } = await useFetch<LatestComment[]>('/api/comments/latest', {
key: 'latest-comments',
default: () => [],
})
const { liveComments } = useLiveComments()
const timeAgo = useTimeAgo()
const mergedComments = computed(() => {
const seen = new Set<number>()
return [...liveComments.value, ...comments.value]
.filter((c) => {
if (seen.has(c.id)) return false
seen.add(c.id)
return true
})
.slice(0, 15)
})
</script>
<template>
<Sidebar side="right" collapsible="none" class="sticky top-0 hidden h-svh shrink-0 overflow-hidden rounded-l-md border-l lg:flex">
<SidebarContent class="px-2 py-2">
<p v-if="!mergedComments.length" class="px-2 py-1 text-sm text-muted-foreground">
Noch keine Kommentare.
</p>
<NuxtLink
v-for="comment in mergedComments"
:key="comment.id"
:to="`/thread/${comment.uploadId}#comment-${comment.id}`"
class="block rounded-md border border-sidebar-border/60 bg-sidebar px-3 py-2.5 text-sm transition-colors hover:border-sidebar-border hover:bg-sidebar-accent"
>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<NuxtImg
v-if="comment.userAvatarUrl"
:src="comment.userAvatarUrl"
:alt="comment.userName"
width="28"
height="28"
fit="cover"
class="size-7 shrink-0 rounded-sm"
/>
<span
v-else
class="flex size-7 shrink-0 items-center justify-center rounded-sm text-xs font-medium"
:style="comment.userColor ? { backgroundColor: comment.userColor, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ comment.userName.slice(0, 2).toUpperCase() }}</span>
<UserHoverCard
:user-id="comment.userId"
:user-name="comment.userName"
:user-color="comment.userColor"
:user-avatar-url="comment.userAvatarUrl"
>
<span
class="truncate text-sm font-medium text-foreground hover:underline"
:style="comment.userColor ? { color: comment.userColor } : undefined"
@click.stop.prevent="navigateTo(`/user/${comment.userId}`)"
>{{ comment.userName }}</span>
</UserHoverCard>
<span class="shrink-0">· {{ timeAgo(comment.createdAt) }}</span>
</div>
<HoverCard :open-delay="250" :close-delay="100">
<HoverCardTrigger as-child>
<div class="mt-1.5 line-clamp-3 break-words">
<CommentContent :content="comment.content" compact /><CommentAttachments v-if="comment.attachments?.length" :attachments="comment.attachments" compact />
</div>
</HoverCardTrigger>
<HoverCardContent side="left" align="start" class="w-80 max-w-[90vw] text-sm">
<div class="max-h-64 overflow-y-auto break-words">
<CommentContent :content="comment.content" />
<CommentAttachments v-if="comment.attachments?.length" :attachments="comment.attachments" />
</div>
</HoverCardContent>
</HoverCard>
</NuxtLink>
</SidebarContent>
</Sidebar>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts" generic="TData">
import type { ColumnDef, Row } from '@tanstack/vue-table'
import { FlexRender, useTable } from '@tanstack/vue-table'
import { dataTableFeatures, type DataTableFeatures } from '@/lib/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
const props = withDefaults(defineProps<{
columns: ColumnDef<DataTableFeatures, TData, unknown>[]
data: TData[]
// Suchfeld über den Global-Filter — searchFn entscheidet, was gematcht wird
searchPlaceholder?: string
searchFn?: (row: TData, query: string) => boolean
pageSize?: number
}>(), {
pageSize: 10,
})
const searchFn = props.searchFn
const table = useTable<DataTableFeatures, TData>({
features: dataTableFeatures,
get columns() { return props.columns },
get data() { return props.data },
globalFilterFn: searchFn
? (row: Row<DataTableFeatures, TData>, _columnId: string, filterValue: unknown) =>
searchFn(row.original, String(filterValue))
: 'auto',
initialState: {
pagination: { pageSize: props.pageSize },
},
})
const pageIndex = computed(() => table.atoms.pagination.get().pageIndex)
const pageCount = computed(() => table.getPageCount())
const globalFilter = computed(() => String(table.atoms.globalFilter.get() ?? ''))
</script>
<template>
<div class="flex flex-col gap-3">
<div v-if="searchPlaceholder" class="relative max-w-xs">
<Icon name="ph:magnifying-glass-fill" class="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
:model-value="globalFilter"
:placeholder="searchPlaceholder"
class="pl-8"
@update:model-value="table.setGlobalFilter(String($event))"
/>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead v-for="header in headerGroup.headers" :key="header.id">
<slot :name="`header-${header.column.id}`" :header="header" :column="header.column">
<button
v-if="header.column.getCanSort()"
type="button"
class="flex items-center gap-1.5 font-medium transition-colors hover:text-foreground"
@click="header.column.toggleSorting()"
>
<FlexRender :header="header" />
<Icon name="ph:arrow-up-fill" v-if="header.column.getIsSorted() === 'asc'" class="size-3.5" />
<Icon name="ph:arrow-down-fill" v-else-if="header.column.getIsSorted() === 'desc'" class="size-3.5" />
<Icon name="ph:arrows-down-up-fill" v-else class="size-3.5 opacity-50" />
</button>
<FlexRender v-else :header="header" />
</slot>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="table.getRowModel().rows.length">
<TableRow v-for="row in table.getRowModel().rows" :key="row.id">
<TableCell v-for="cell in row.getAllCells()" :key="cell.id">
<slot :name="`cell-${cell.column.id}`" :row="row" :cell="cell">
<FlexRender :cell="cell" />
</slot>
</TableCell>
</TableRow>
</template>
<TableEmpty v-else :colspan="columns.length">
Keine Einträge.
</TableEmpty>
</TableBody>
</Table>
</div>
<div v-if="pageCount > 1" class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">
Seite {{ pageIndex + 1 }} von {{ pageCount }}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
>
Zurück
</Button>
<Button
variant="outline"
size="sm"
:disabled="!table.getCanNextPage()"
@click="table.nextPage()"
>
Weiter
</Button>
</div>
</div>
</div>
</template>
+232
View File
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, type CSSProperties } from 'vue';
interface DepthTextProps {
text?: string;
layers?: number;
depth?: number;
faceColor?: string;
depthColor?: string;
tilt?: number;
pointerTracking?: boolean;
smoothing?: number;
perspective?: number;
autoOrbit?: boolean;
orbitSpeed?: number;
fontSize?: string;
fontWeight?: number | string;
shadow?: boolean;
}
interface DepthLayer {
index: number;
color: string;
transform: string;
}
const MAX_LAYERS = 64;
const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);
const getLayerColor = (faceColor: string, depthColor: string, index: number, total: number): string => {
const progress = total <= 1 ? 1 : index / total;
const eased = progress * progress;
const faceMix = Math.round((1 - eased) * 72 + 4);
return `color-mix(in srgb, ${faceColor} ${faceMix}%, ${depthColor})`;
};
const getTransform = (rotateX: number, rotateY: number): string =>
`rotateX(${rotateX.toFixed(3)}deg) rotateY(${rotateY.toFixed(3)}deg)`;
const props = withDefaults(defineProps<DepthTextProps>(), {
text: 'Elevate',
layers: 34,
depth: 2.4,
faceColor: '#f8fafc',
depthColor: '#117b5a',
tilt: 7.5,
pointerTracking: true,
smoothing: 0.14,
perspective: 900,
autoOrbit: true,
orbitSpeed: 0.35,
fontSize: 'clamp(3rem, 12vw, 7rem)',
fontWeight: 900,
shadow: true
});
const rootRef = ref<HTMLSpanElement | null>(null);
const stageRef = ref<HTMLSpanElement | null>(null);
const safeLayers = computed(() => clamp(Math.round(Number(props.layers) || 1), 2, MAX_LAYERS));
const safeDepth = computed(() => clamp(Number(props.depth) || 0, 0, 12));
const safeTilt = computed(() => clamp(Number(props.tilt) || 0, 0, 12));
const safeSmoothing = computed(() => clamp(Number(props.smoothing) || 0.14, 0.02, 0.35));
const safePerspective = computed(() => clamp(Number(props.perspective) || 900, 300, 2000));
const safeOrbitSpeed = computed(() => clamp(Number(props.orbitSpeed) || 0, 0, 2));
const baseRotation = computed(() => ({ x: -safeTilt.value * 0.32, y: safeTilt.value * 0.42 }));
const depthLayers = computed<DepthLayer[]>(() =>
Array.from({ length: safeLayers.value }, (_, layerIndex) => {
const index = safeLayers.value - layerIndex;
return {
index,
color: getLayerColor(props.faceColor, props.depthColor, index, safeLayers.value),
transform: `translateZ(${-index * safeDepth.value}px)`
};
})
);
const rootStyle = computed<CSSProperties>(() => ({
perspective: `${safePerspective.value}px`,
perspectiveOrigin: '50% 50%',
contain: 'layout paint',
isolation: 'isolate'
}));
const stageStyle = computed<CSSProperties>(() => ({
transformStyle: 'preserve-3d',
transform: getTransform(baseRotation.value.x, baseRotation.value.y),
transformOrigin: '50% 50%',
willChange: 'transform'
}));
const textStyle = computed<CSSProperties>(() => ({
fontSize: props.fontSize,
fontWeight: props.fontWeight,
lineHeight: 0.86,
letterSpacing: '-0.065em',
whiteSpace: 'nowrap',
userSelect: 'none',
transformStyle: 'preserve-3d',
backfaceVisibility: 'hidden',
fontKerning: 'normal',
textRendering: 'geometricPrecision'
}));
let stopMotion: (() => void) | null = null;
const setupMotion = () => {
const root = rootRef.value;
const stage = stageRef.value;
if (!root || !stage || typeof window === 'undefined') return;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
const canTrackPointer = props.pointerTracking && finePointer && !reducedMotion;
if (reducedMotion) {
stage.style.transform = getTransform(baseRotation.value.x, baseRotation.value.y);
return;
}
let frameId = 0;
let activePointer = false;
const startTime = performance.now();
const current = { ...baseRotation.value };
const target = { ...baseRotation.value };
const applyTransform = () => {
stage.style.transform = getTransform(current.x, current.y);
};
const handlePointerMove = (event: PointerEvent) => {
const rect = root.getBoundingClientRect();
if (!rect.width || !rect.height) return;
activePointer = true;
const x = clamp((event.clientX - (rect.left + rect.width / 2)) / (rect.width * 0.8), -1, 1);
const y = clamp((event.clientY - (rect.top + rect.height / 2)) / (rect.height * 0.8), -1, 1);
target.x = baseRotation.value.x - y * safeTilt.value;
target.y = baseRotation.value.y + x * safeTilt.value;
};
const handlePointerLeave = () => {
activePointer = false;
target.x = baseRotation.value.x;
target.y = baseRotation.value.y;
};
if (canTrackPointer) {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerleave', handlePointerLeave);
window.addEventListener('blur', handlePointerLeave);
}
const tick = (now: number) => {
if ((!canTrackPointer || !activePointer) && props.autoOrbit) {
const elapsed = (now - startTime) / 1000;
const orbit = elapsed * safeOrbitSpeed.value * Math.PI * 2;
const fallbackAmount = canTrackPointer ? 0.18 : 0.55;
target.x = baseRotation.value.x + Math.sin(orbit) * safeTilt.value * fallbackAmount;
target.y = baseRotation.value.y + Math.cos(orbit * 0.85) * safeTilt.value * fallbackAmount;
}
current.x += (target.x - current.x) * safeSmoothing.value;
current.y += (target.y - current.y) * safeSmoothing.value;
applyTransform();
frameId = requestAnimationFrame(tick);
};
applyTransform();
frameId = requestAnimationFrame(tick);
stopMotion = () => {
if (canTrackPointer) {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerleave', handlePointerLeave);
window.removeEventListener('blur', handlePointerLeave);
}
cancelAnimationFrame(frameId);
};
};
onMounted(() => {
setupMotion();
});
onUnmounted(() => {
stopMotion?.();
stopMotion = null;
});
watch(
() => [props.autoOrbit, props.pointerTracking, safeOrbitSpeed.value, safeSmoothing.value, safeTilt.value],
() => {
stopMotion?.();
stopMotion = null;
setupMotion();
}
);
</script>
<template>
<span ref="rootRef" class="inline-block" :style="rootStyle">
<span ref="stageRef" class="inline-grid relative place-items-center" :style="stageStyle">
<span
v-for="layer in depthLayers"
:key="layer.index"
aria-hidden="true"
class="inline-block z-0 absolute inset-0 brightness-95 saturate-95 pointer-events-none transition-[transform,color] duration-300 ease-out"
:style="{ ...textStyle, color: layer.color, transform: layer.transform }"
>
{{ text }}
</span>
<span
class="inline-block z-10 relative transition-[color] duration-300"
:style="{
...textStyle,
color: faceColor,
textShadow: shadow
? `0 22px 34px color-mix(in srgb, ${depthColor} 36%, transparent), 0 4px 8px rgba(0, 0, 0, 0.28)`
: 'none',
transform: 'translateZ(0.6px)'
}"
>
{{ text }}
</span>
</span>
</span>
</template>
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
import { Button } from '@/components/ui/button'
const model = defineModel<File | null>()
const props = withDefaults(defineProps<{
disabled?: boolean
accept?: string
hint?: string
}>(), {
accept: 'image/*,video/*,audio/*,.mp3,.flac',
hint: 'Bilder, Videos & Audio, max. 200 MB',
})
const dropzoneRef = ref<HTMLElement>()
const inputRef = ref<HTMLInputElement>()
const previewUrl = ref<string | null>(null)
const { isOverDropZone } = useDropZone(dropzoneRef, {
dataTypes: types => types.some(isMediaItemType),
onDrop(files) {
if (props.disabled) return
const file = files?.find(isMediaFile)
if (file) select(file)
else if (files?.length) toast.error('Nur Bilder, Videos und Audiodateien sind erlaubt')
},
})
// Drag-Events nicht bis zur seitenweiten Dropzone (document.body) durchreichen —
// sonst würde ein Drop hier zusätzlich den globalen Upload auslösen
useEventListener(dropzoneRef, ['dragenter', 'dragover', 'dragleave', 'drop'], e => e.stopPropagation())
function select(file: File | null) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = file?.type.startsWith('image/') ? URL.createObjectURL(file) : null
model.value = file
if (inputRef.value) inputRef.value.value = ''
}
function openPicker() {
if (!props.disabled) inputRef.value?.click()
}
function formatSize(bytes: number) {
return bytes >= 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${Math.max(1, Math.round(bytes / 1024))} KB`
}
onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
</script>
<template>
<div
ref="dropzoneRef"
role="button"
tabindex="0"
:aria-disabled="disabled"
class="flex min-h-32 cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 text-center text-sm transition-colors"
:class="[
isOverDropZone ? 'border-primary bg-accent' : 'border-primary/40 hover:border-primary',
disabled && 'pointer-events-none opacity-50',
]"
@click="openPicker"
@keydown.enter.prevent="openPicker"
@keydown.space.prevent="openPicker"
>
<input
ref="inputRef"
type="file"
:accept="accept"
class="hidden"
@change="select(($event.target as HTMLInputElement).files?.[0] ?? null)"
>
<template v-if="model">
<img
v-if="previewUrl"
:src="previewUrl"
:alt="model.name"
class="max-h-32 rounded-md"
>
<span class="max-w-full truncate font-medium">{{ model.name }}</span>
<span class="text-xs text-muted-foreground">{{ formatSize(model.size) }}</span>
<Button
type="button"
variant="ghost"
size="sm"
@click.stop="select(null)"
>
<Icon name="ph:x-fill" class="size-4" />
Entfernen
</Button>
</template>
<template v-else>
<Icon name="ph:cloud-arrow-up-fill" class="size-8 text-muted-foreground" />
<span>Datei hierher ziehen oder klicken</span>
<span class="text-xs text-muted-foreground">{{ hint }}</span>
</template>
</div>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { onKeyStroke } from '@vueuse/core'
defineProps<{
src: string
alt?: string
}>()
const emit = defineEmits<{
close: []
}>()
const scale = ref(1)
const offset = ref({ x: 0, y: 0 })
const dragging = ref(false)
let last = { x: 0, y: 0 }
function onWheel(event: WheelEvent) {
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15
scale.value = Math.min(10, Math.max(0.2, scale.value * factor))
}
function onPointerDown(event: PointerEvent) {
dragging.value = true
last = { x: event.clientX, y: event.clientY }
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent) {
if (!dragging.value) return
offset.value = {
x: offset.value.x + event.clientX - last.x,
y: offset.value.y + event.clientY - last.y,
}
last = { x: event.clientX, y: event.clientY }
}
function onPointerUp() {
dragging.value = false
}
function reset() {
scale.value = 1
offset.value = { x: 0, y: 0 }
}
onKeyStroke('Escape', () => emit('close'))
</script>
<template>
<Teleport to="body">
<div
class="fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-black/90"
@wheel.prevent="onWheel"
@click="emit('close')"
>
<NuxtImg
:src="src"
:alt="alt"
draggable="false"
class="max-h-[95vh] max-w-[95vw] touch-none select-none"
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
:style="{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})` }"
@click.stop
@dblclick="reset"
@pointerdown.prevent="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
/>
<button
type="button"
class="absolute top-4 right-4 rounded-md bg-black/50 p-2 text-white hover:bg-black/70"
@click="emit('close')"
>
<Icon name="ph:x-fill" class="size-5" />
<span class="sr-only">Schließen</span>
</button>
</div>
</Teleport>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
const props = defineProps<{
url: string
}>()
const image = ref<string | null>(null)
const title = ref<string | null>(null)
const hostname = computed(() => {
try {
return new URL(props.url).hostname.replace(/^www\./, '')
}
catch {
return props.url
}
})
if (import.meta.client) {
$fetch<{ image: string | null, title: string | null }>('/api/link-preview', {
query: { url: props.url },
})
.then((res) => {
image.value = res.image
title.value = res.title
})
.catch(() => {})
}
</script>
<template>
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
class="mt-2 flex overflow-hidden rounded-md border transition-colors hover:border-accent"
@click.stop
>
<div class="flex size-24 shrink-0 items-center justify-center overflow-hidden bg-muted">
<img
v-if="image"
:src="image"
:alt="title ?? hostname"
loading="lazy"
class="h-full w-full object-cover"
>
<Icon name="ph:link-fill" v-else class="size-6 text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-col justify-center gap-0.5 px-3 py-2">
<p class="truncate font-medium">{{ title ?? hostname }}</p>
<p class="truncate text-xs text-muted-foreground">{{ url }}</p>
</div>
</a>
</template>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { activeMention } from '~~/shared/utils/mentions'
import type { MentionUser } from '~~/shared/utils/mentions'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
const model = defineModel<string>({ required: true })
const props = defineProps<{ placeholder?: string }>()
const { data: users } = useMentionUsers()
const listId = useId()
const caret = shallowRef(0)
const focused = shallowRef(false)
const dismissed = shallowRef(false)
const selected = shallowRef(0)
let textarea: HTMLTextAreaElement | null = null
const mention = computed(() => activeMention(model.value, caret.value))
const suggestions = computed(() => {
if (!mention.value) return []
const query = mention.value.query.toLowerCase()
return users.value.filter(u => u.name.toLowerCase().startsWith(query)).slice(0, 8)
})
const open = computed(() => focused.value && !dismissed.value && suggestions.value.length > 0)
function updateCaret(event: Event) {
textarea = event.target as HTMLTextAreaElement
caret.value = textarea.selectionStart
focused.value = true
dismissed.value = false
selected.value = 0
}
async function choose(user: MentionUser) {
if (!mention.value || !textarea) return
const { start, end } = mention.value
const suffix = model.value.slice(end).replace(/^[\p{L}\p{N}_-]*/u, '')
const insertion = `@${user.name} `
model.value = model.value.slice(0, start) + insertion + suffix
dismissed.value = true
await nextTick()
textarea.focus({ preventScroll: true })
textarea.setSelectionRange(start + insertion.length, start + insertion.length)
caret.value = start + insertion.length
}
function onKeydown(event: KeyboardEvent) {
if (!open.value || event.isComposing) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
selected.value = (selected.value + (event.key === 'ArrowDown' ? 1 : -1) + suggestions.value.length) % suggestions.value.length
}
else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
const user = suggestions.value[selected.value]
if (user) choose(user)
}
else if (event.key === 'Escape') {
event.preventDefault()
dismissed.value = true
}
}
</script>
<template>
<Popover :open="open" @update:open="value => { if (!value) dismissed = true }">
<PopoverAnchor as-child>
<div class="relative">
<Textarea
v-model="model"
:placeholder="props.placeholder"
class="min-h-32 pb-9"
aria-label="Kommentar"
aria-autocomplete="list"
:aria-expanded="open"
:aria-controls="open ? listId : undefined"
:aria-activedescendant="open ? `${listId}-${selected}` : undefined"
@input="updateCaret"
@click="updateCaret"
@keyup="event => { if (!['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(event.key)) updateCaret(event) }"
@focus="updateCaret"
@blur="focused = false"
@keydown="onKeydown"
/>
<slot />
</div>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
class="gap-1 rounded-md p-1"
@open-auto-focus.prevent
@close-auto-focus.prevent
@interact-outside="event => { if (event.target === textarea) event.preventDefault() }"
>
<div :id="listId" role="listbox" aria-label="Benutzer erwähnen">
<button
v-for="(user, index) in suggestions"
:id="`${listId}-${index}`"
:key="user.id"
type="button"
role="option"
:aria-selected="index === selected"
:class="cn('flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-left', index === selected && 'bg-accent text-accent-foreground')"
@pointerdown.prevent
@pointermove="selected = index"
@click="choose(user)"
>
<Icon name="ph:user-fill" class="size-4 shrink-0" :style="user.color ? { color: user.color } : undefined" />
<span class="truncate">@{{ user.name }}</span>
</button>
</div>
</PopoverContent>
</Popover>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
const motd = useMotd()
const { data } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
// SSR-/Init-Wert in den State übernehmen — danach kommen Updates per WebSocket
if (data.value.message) motd.value = data.value.message
</script>
<template>
<div v-if="motd" class="border-b px-4 py-2 lg:px-6">
<p class="flex items-center gap-2 text-sm">
<Icon name="ph:warning-octagon-fill" class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 whitespace-pre-wrap">{{ motd }}</span>
</p>
</div>
</template>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import type { AppNotification } from '@/composables/useNotifications'
const { notifications, unreadCount, markRead } = useNotifications()
const open = ref(false)
const timeAgo = useTimeAgo()
async function goTo(n: AppNotification) {
open.value = false
await markRead(n.id)
await navigateTo(`/thread/${n.uploadId}#comment-${n.commentId}`)
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost" size="icon" class="relative" aria-label="Benachrichtigungen">
<Icon name="ph:bell-fill" class="size-5" />
<span
v-if="unreadCount"
class="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-accent px-1 text-[10px] font-semibold text-accent-foreground"
>{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-80 gap-0 p-0">
<div class="max-h-96 overflow-y-auto">
<p v-if="!notifications.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
Keine Benachrichtigungen.
</p>
<button
v-for="n in notifications"
:key="n.id"
type="button"
class="flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/10"
@click="goTo(n)"
>
<div class="size-12 shrink-0 overflow-hidden rounded-md bg-muted">
<NuxtImg
v-if="n.thumbUrl"
:src="n.thumbUrl"
:alt="n.actorName"
width="96"
height="96"
fit="cover"
class="h-full w-full object-cover"
/>
<video
v-else-if="n.uploadType === 'video'"
:src="n.url"
preload="metadata"
muted
playsinline
class="h-full w-full object-cover"
/>
<div v-else class="flex h-full w-full items-center justify-center">
<Icon name="ph:link-fill" class="size-4 text-muted-foreground" />
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-1.5">
<span
class="truncate font-medium"
:style="n.actorColor ? { color: n.actorColor } : undefined"
>{{ n.actorName }}</span>
<span class="shrink-0 text-muted-foreground">{{ n.isMention ? 'hat dich erwähnt' : 'hat kommentiert' }}</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ timeAgo(n.createdAt) }}</span>
</div>
<p class="mt-0.5 line-clamp-2 break-words text-muted-foreground">
{{ n.content }}
</p>
</div>
</button>
</div>
</PopoverContent>
</Popover>
</template>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const { user, can, logout } = useAuth()
const { uniform, toggle: toggleGridMode } = useGridMode()
const { open: paletteOpen } = useCommandPalette()
const route = useRoute()
async function onLogoClick(e: MouseEvent) {
if (route.path !== '/') return
e.preventDefault()
await refreshNuxtData()
}
const initials = computed(() => user.value?.name.slice(0, 2).toUpperCase() ?? '')
async function onLogout() {
await logout()
await navigateTo('/')
}
</script>
<template>
<header class="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background">
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<NuxtLink
to="/"
class="mr-2 font-logo text-[2.4rem] leading-none tracking-tight transition-colors hover:text-accent"
@click="onLogoClick"
>
BRETT
</NuxtLink>
<UploadPopover />
<Button variant="ghost" as-child>
<NuxtLink to="/users" class="flex items-center gap-1.5">
<Icon name="ph:users-fill" class="size-4" />
Benutzer
</NuxtLink>
</Button>
<Button v-if="can('admin.access')" variant="ghost" as-child>
<NuxtLink to="/admin" class="flex items-center gap-1.5">
<Icon name="ph:shield-fill" class="size-4" />
Admin
</NuxtLink>
</Button>
<button
type="button"
class="ml-auto flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label="Schnellsuche öffnen (Strg+K)"
@click="paletteOpen = true"
>
<Icon name="ph:magnifying-glass-fill" class="size-4" />
<span class="hidden text-left sm:inline">Suchen…</span>
<kbd class="hidden rounded-sm border bg-background px-1.5 text-xs sm:inline">Strg K</kbd>
</button>
<div class="flex items-center gap-3">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
@click="toggleGridMode"
>
<Icon name="ph:grid-nine-fill" v-if="uniform" class="size-5" />
<Icon name="ph:squares-four-fill" v-else class="size-5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ uniform ? 'Bento-Layout' : 'Einheitliche Größe' }}</TooltipContent>
</Tooltip>
<ThemeSelector />
<template v-if="user">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger class="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 outline-none hover:bg-accent">
<Avatar class="size-8">
<AvatarImage v-if="user.avatarUrl" :src="user.avatarUrl" :alt="user.name" />
<AvatarFallback
class="text-xs"
:style="user.color ? { backgroundColor: user.color, color: '#fff' } : undefined"
>
{{ initials }}
</AvatarFallback>
</Avatar>
<span
class="text-sm font-medium"
:style="user.color ? { color: user.color } : undefined"
>{{ user.name }}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuLabel class="truncate">
{{ user.name }}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem as-child>
<NuxtLink :to="`/user/${user.id}`" class="flex items-center gap-2">
<Icon name="ph:user-fill" class="size-4" />
Profil
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<NuxtLink to="/settings" class="flex items-center gap-2">
<Icon name="ph:gear-fill" class="size-4" />
Einstellungen
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" class="flex items-center gap-2" @click="onLogout">
<Icon name="ph:sign-out-fill" class="size-4" />
Ausloggen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
<template v-else>
<Button variant="ghost" as-child>
<NuxtLink to="/login">Login</NuxtLink>
</Button>
<Button as-child>
<NuxtLink to="/register">Registrieren</NuxtLink>
</Button>
</template>
</div>
</div>
</header>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const mode = useTheme()
const themes = [
{ value: 'standard', label: 'Standard' },
{ value: 'discord', label: 'Discord' },
{ value: 'cyberpunk', label: 'Cyberpunk' },
{ value: 'spring', label: 'Spring' },
{ value: 'terminal', label: 'Terminal' },
{ value: 'infoseer', label: 'Infoseer' },
{ value: 'homebrew', label: 'Homebrew' },
{ value: 'florense', label: 'Florense' },
{ value: 'kawi-green', label: 'Kawi Green' },
{ value: 'clonz', label: 'Clo.nz' },
{ value: 'clawmorphism', label: 'Clawmorphism' },
{ value: 'realmorphism', label: 'Realmorphism' },
{ value: 'amber', label: 'Amber' },
{ value: 'neo', label: 'Neo' },
{ value: 'neo-pink', label: 'Neo Pink' },
{ value: 'neo-mint', label: 'Neo Mint' },
] as const
</script>
<template>
<Select v-model="mode">
<SelectTrigger class="h-9 w-[150px]">
<div class="flex items-center gap-2">
<Icon name="ph:palette-fill" class="size-4 text-muted-foreground" />
<SelectValue placeholder="Theme" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="t in themes" :key="t.value" :value="t.value">
{{ t.label }}
</SelectItem>
</SelectContent>
</Select>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{ uploadId: string, enabled: boolean }>()
const emit = defineEmits<{ change: [uploadId: string, enabled: boolean] }>()
const busy = shallowRef(false)
const label = computed(() => props.enabled
? 'Benachrichtigungen für diesen Thread deaktivieren'
: 'Benachrichtigungen für diesen Thread aktivieren')
async function toggle() {
if (busy.value) return
busy.value = true
const uploadId = props.uploadId
try {
const result = await $fetch<{ enabled: boolean }>(`/api/uploads/${uploadId}/subscription`, {
method: 'PUT',
body: { enabled: !props.enabled },
})
emit('change', uploadId, result.enabled)
toast.success(result.enabled ? 'Thread-Benachrichtigungen aktiviert' : 'Thread-Benachrichtigungen deaktiviert')
}
catch {
toast.error('Abonnement konnte nicht geändert werden')
}
finally {
busy.value = false
}
}
</script>
<template>
<Tooltip>
<TooltipTrigger as-child>
<Button
type="button"
:variant="enabled ? 'secondary' : 'ghost'"
size="icon"
:disabled="busy"
:aria-label="label"
:aria-pressed="enabled"
@click="toggle"
>
<Icon :name="enabled ? 'ph:bell-fill' : 'ph:bell-slash-fill'" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ label }}</TooltipContent>
</Tooltip>
</template>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
interface ListedUser {
id: number
name: string
color: string | null
avatarUrl: string | null
stats?: { uploads: number }
}
const route = useRoute()
const router = useRouter()
const TYPE_OPTIONS = [
{ value: 'image', label: 'Bilder', icon: 'ph:image-fill' },
{ value: 'video', label: 'Videos', icon: 'ph:video-camera-fill' },
{ value: 'audio', label: 'Audio', icon: 'ph:music-note-fill' },
{ value: 'youtube', label: 'YouTube', icon: 'simple-icons:youtube' },
{ value: 'twitch', label: 'Twitch', icon: 'simple-icons:twitch' },
{ value: 'link', label: 'Links', icon: 'ph:link-fill' },
] as const
const DAY_OPTIONS = [
{ value: '1', label: 'Heute' },
{ value: '7', label: '7 Tage' },
{ value: '30', label: '30 Tage' },
{ value: '365', label: 'Letztes Jahr' },
] as const
const open = ref(false)
const uploaderOpen = ref(false)
const users = ref<ListedUser[]>([])
const usersLoaded = ref(false)
async function loadUsers() {
if (usersLoaded.value) return
usersLoaded.value = true
try {
users.value = await $fetch<ListedUser[]>('/api/users')
}
catch {
users.value = []
}
}
watch(open, v => v && loadUsers())
onMounted(() => {
if (route.query.uploader) loadUsers()
})
// Für die Uploader-Auswahl nur Nutzer mit Uploads anzeigen
const uploaders = computed(() => users.value.filter(u => !u.stats || u.stats.uploads > 0))
const tagFilter = computed(() => typeof route.query.tag === 'string' ? route.query.tag : undefined)
const selectedTypes = computed<string[]>({
get: () => typeof route.query.type === 'string' ? route.query.type.split(',').filter(Boolean) : [],
set: v => setQuery({ type: v.length ? v.join(',') : undefined }),
})
const uploaderId = computed(() => typeof route.query.uploader === 'string' ? route.query.uploader : '')
const selectedUser = computed(() => users.value.find(u => String(u.id) === uploaderId.value))
const daysModel = computed<string>({
get: () => typeof route.query.days === 'string' && route.query.days ? route.query.days : 'all',
set: v => setQuery({ days: v === 'all' ? undefined : v }),
})
const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value))
const typeLabel = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.label ?? value
const typeIcon = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.icon ?? 'ph:image-fill'
const activeCount = computed(() =>
(tagFilter.value ? 1 : 0) + selectedTypes.value.length + (uploaderId.value ? 1 : 0) + (daysOption.value ? 1 : 0),
)
function setQuery(patch: Record<string, string | undefined>) {
const query = { ...route.query }
for (const [key, value] of Object.entries(patch)) {
if (value) query[key] = value
else delete query[key]
}
router.replace({ query })
}
function selectUser(u: ListedUser | null) {
setQuery({ uploader: u ? String(u.id) : undefined })
uploaderOpen.value = false
}
function clearFilters() {
setQuery({ tag: undefined, type: undefined, uploader: undefined, days: undefined })
}
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<Icon name="ph:sliders-horizontal-fill" class="size-4" />
Filter
<Badge v-if="activeCount" variant="secondary" class="ml-0.5 px-1.5 text-xs">{{ activeCount }}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-72" align="start">
<FieldGroup class="gap-4">
<Field>
<FieldLabel>Typ</FieldLabel>
<ToggleGroup v-model="selectedTypes" type="multiple" variant="outline" :spacing="2" class="flex-wrap justify-start">
<ToggleGroupItem
v-for="t in TYPE_OPTIONS"
:key="t.value"
:value="t.value"
:aria-label="t.label"
class="gap-1.5 rounded-md px-2.5"
>
<Icon :name="t.icon" class="size-4" />
{{ t.label }}
</ToggleGroupItem>
</ToggleGroup>
</Field>
<Field>
<FieldLabel>Uploader</FieldLabel>
<Popover v-model:open="uploaderOpen">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="uploaderOpen" class="w-full justify-between font-normal">
<span class="flex items-center gap-1.5 truncate">
<Icon name="ph:user-fill" class="size-4 shrink-0 text-muted-foreground" />
<span :style="selectedUser?.color ? { color: selectedUser.color } : undefined" class="truncate">
{{ selectedUser?.name ?? 'Alle Uploader' }}
</span>
</span>
<Icon name="ph:caret-up-down-fill" class="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-0">
<Command>
<CommandInput placeholder="Nutzer suchen…" />
<CommandList>
<CommandEmpty>Kein Nutzer gefunden.</CommandEmpty>
<CommandGroup>
<CommandItem value="alle" class="gap-2" @select="selectUser(null)">
<Icon name="ph:check-fill" :class="cn('size-4', !uploaderId ? 'opacity-100' : 'opacity-0')" />
Alle Uploader
</CommandItem>
<CommandItem
v-for="u in uploaders"
:key="u.id"
:value="u.name"
class="gap-2"
@select="selectUser(u)"
>
<Icon name="ph:check-fill" :class="cn('size-4', String(u.id) === uploaderId ? 'opacity-100' : 'opacity-0')" />
<NuxtImg
v-if="u.avatarUrl"
:src="u.avatarUrl"
:alt="u.name"
width="20"
height="20"
fit="cover"
class="size-5 rounded-sm"
/>
<span
v-else
class="flex size-5 items-center justify-center rounded-sm text-[10px] font-medium"
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ u.name.slice(0, 2).toUpperCase() }}</span>
<span :style="u.color ? { color: u.color } : undefined">{{ u.name }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Field>
<Field>
<FieldLabel>Zeitraum</FieldLabel>
<Select v-model="daysModel">
<SelectTrigger class="w-full">
<SelectValue placeholder="Alle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Gesamter Zeitraum</SelectItem>
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ d.label }}</SelectItem>
</SelectContent>
</Select>
</Field>
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
Zurücksetzen
</Button>
</FieldGroup>
</PopoverContent>
</Popover>
<span v-if="tagFilter" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:tag-fill" class="size-3.5" />
{{ tagFilter }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Tag-Filter entfernen"
@click="setQuery({ tag: undefined })"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<span v-for="t in selectedTypes" :key="t" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon :name="typeIcon(t)" class="size-3.5" />
{{ typeLabel(t) }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="`Typ-Filter ${typeLabel(t)} entfernen`"
@click="selectedTypes = selectedTypes.filter(v => v !== t)"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<span v-if="uploaderId" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:user-fill" class="size-3.5" />
{{ selectedUser?.name ?? '…' }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Uploader-Filter entfernen"
@click="setQuery({ uploader: undefined })"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<span v-if="daysOption" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:calendar-fill" class="size-3.5" />
{{ daysOption.label }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
aria-label="Zeitraum-Filter entfernen"
@click="setQuery({ days: undefined })"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
</div>
</template>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import FileDropzone from '@/components/FileDropzone.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
const { user } = useAuth()
const open = ref(false)
const file = ref<File | null>(null)
const url = ref('')
const errorMessage = ref('')
const loading = ref(false)
async function onSubmit() {
if (loading.value || (!file.value && !url.value.trim())) return
loading.value = true
errorMessage.value = ''
try {
if (url.value.trim()) {
await $fetch('/api/uploads', {
method: 'POST',
body: { url: url.value.trim() },
})
}
else {
const formData = new FormData()
formData.append('file', file.value!)
await $fetch('/api/uploads', { method: 'POST', body: formData })
}
file.value = null
url.value = ''
open.value = false
await refreshNuxtData('uploads')
await navigateTo('/')
}
catch (err) {
const e = err as { data?: { statusMessage?: string, message?: string } }
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Upload fehlgeschlagen'
}
finally {
loading.value = false
}
}
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="ghost">
<Icon name="ph:cloud-arrow-up-fill" class="size-4" />
Hochladen
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-160">
<template v-if="user">
<PopoverHeader>
<PopoverTitle>Hochladen</PopoverTitle>
<PopoverDescription>Datei oder URL — Bilder, Videos, Audio (MP3/FLAC), YouTube- und Twitch-Links.</PopoverDescription>
</PopoverHeader>
<form class="space-y-4" @submit.prevent="onSubmit">
<div class="space-y-1.5">
<Label>Datei</Label>
<FileDropzone v-model="file" :disabled="!!url.trim()" />
</div>
<div class="space-y-1.5">
<Label for="upload-url">oder URL</Label>
<Input id="upload-url" v-model="url" type="url" placeholder="https://…" :disabled="!!file" />
<p class="text-xs text-muted-foreground">
Direkte Bild-/Video-URLs werden gespeichert, YouTube- und Twitch-Links eingebettet.
</p>
</div>
<p v-if="errorMessage" class="text-sm text-destructive">
{{ errorMessage }}
</p>
<Button type="submit" class="w-full" :disabled="loading || (!file && !url.trim())">
{{ loading ? 'Lädt hoch…' : 'Hochladen' }}
</Button>
</form>
</template>
<p v-else class="text-sm text-muted-foreground">
<NuxtLink to="/login" class="text-primary underline" @click="open = false">
Melde dich an
</NuxtLink>, um Dateien hochzuladen.
</p>
</PopoverContent>
</Popover>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{ uploadId: string }>()
const emit = defineEmits<{ subscriptionChange: [uploadId: string, enabled: boolean] }>()
const { user } = useAuth()
const { reactionsFor, toggle } = useUploadReactions()
const busy = ref(false)
const pending = ref<string | null>(null)
const reactions = computed(() => reactionsFor(props.uploadId))
const BURST_LINES = Array.from({ length: 12 }, (_, i) => ({
angle: i * 30,
long: i % 2 === 0,
}))
const burstEmoji = ref<string | null>(null)
const burstNonce = ref(0)
async function pick(emoji: string) {
if (!user.value) {
toast.error('Melde dich an, um zu reagieren.')
return
}
if (busy.value) return
busy.value = true
pending.value = emoji
burstEmoji.value = emoji
burstNonce.value++
try {
const uploadId = props.uploadId
const result = await toggle(uploadId, emoji)
emit('subscriptionChange', uploadId, result.isSubscribed)
}
catch {
toast.error('Reaktion fehlgeschlagen')
}
finally {
busy.value = false
pending.value = null
}
}
</script>
<template>
<div v-if="reactions.length" class="flex flex-wrap items-center gap-2">
<Tooltip v-for="r in reactions" :key="r.emoji">
<TooltipTrigger as-child>
<button
type="button"
class="relative flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1 transition-colors"
:class="cn(
r.me
? 'border-foreground/40 bg-secondary hover:border-foreground/60'
: 'border-border bg-muted/40 hover:border-foreground/30',
pending === r.emoji && 'animate-pulse',
)"
@click="pick(r.emoji)"
>
<span v-if="burstEmoji === r.emoji" :key="burstNonce" class="pointer-events-none absolute inset-0" aria-hidden="true">
<i
v-for="l in BURST_LINES"
:key="l.angle"
class="burst-line"
:style="{ '--a': `${l.angle}deg`, '--h': l.long ? '9px' : '6px', '--d': l.long ? '-26px' : '-19px' }"
/>
</span>
<span :key="burstNonce" class="text-xl leading-none" :class="burstEmoji === r.emoji && 'emoji-pop'">{{ r.emoji }}</span>
<span class="text-sm font-medium tabular-nums" :class="r.me ? 'text-foreground' : 'text-muted-foreground'">{{ r.count }}</span>
</button>
</TooltipTrigger>
<TooltipContent>{{ r.names.join(', ') }}</TooltipContent>
</Tooltip>
</div>
</template>
<style scoped>
.burst-line {
position: absolute;
left: 50%;
top: 50%;
width: 3px;
height: var(--h);
margin-left: -1.5px;
margin-top: -3px;
border-radius: 1.5px;
background: var(--color-primary);
opacity: 0;
}
.emoji-pop {
display: inline-block;
}
@media (prefers-reduced-motion: no-preference) {
.burst-line {
animation: burst-line 0.55s cubic-bezier(0.15, 0.85, 0.35, 1) forwards;
}
.emoji-pop {
animation: emoji-pop 0.3s ease-out;
}
}
@keyframes burst-line {
0% {
opacity: 1;
transform: rotate(var(--a)) translateY(-4px) scaleY(0.3);
}
30% {
opacity: 1;
transform: rotate(var(--a)) translateY(calc(var(--d) * 0.7)) scaleY(1.15);
}
100% {
opacity: 0;
transform: rotate(var(--a)) translateY(var(--d)) scaleY(0.5);
}
}
@keyframes emoji-pop {
0% { transform: scale(1); }
40% { transform: scale(1.35); }
100% { transform: scale(1); }
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { toast } from 'vue-sonner'
interface TileUpload {
id: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
thumbUrl?: string | null
userId?: number
userName?: string
userColor?: string | null
userAvatarUrl?: string | null
commentCount?: number
}
const props = defineProps<{ upload: TileUpload }>()
const { unreadUploadIds } = useNotifications()
const { uniform } = useGridMode()
const lastViewedId = useLastViewedUpload()
const pendingMedia = usePendingUploadMedia()
const hasNotification = computed(() => unreadUploadIds.value.has(props.upload.id))
// LQIP: winzige geblurrede Variante sofort, echtes Thumb fadet nach.
// Nur für Quellen, die IPX verarbeiten kann (lokal oder gewhitelistete Domains)
const thumbSrc = computed(() => props.upload.thumbUrl ?? (props.upload.type === 'image' ? props.upload.url : null))
const canPlaceholder = computed(() => {
const s = thumbSrc.value
return !!s && (s.startsWith('/') || s.startsWith('https://i.ytimg.com/') || s.startsWith('https://static-cdn.jtvnw.net/'))
})
const indicator = useLoadingIndicator()
const router = useRouter()
const navigating = ref(false)
const spanClass = computed(() => {
if (uniform.value) return ''
const count = props.upload.commentCount ?? 0
if (count >= 7) return 'col-span-2 row-span-2 sm:col-span-3 sm:row-span-3'
if (count >= 1) return 'col-span-2 row-span-2'
return ''
})
// Klick auf ein Thumbnail:
// 1. Ladeindikator oben sofort starten
// 2. Thread-Daten + Bild/Video vorladen (min. 600 ms, damit der Indikator sichtbar läuft)
// 3. Wenn geladen: viewTransitionName setzen und via startViewTransition navigieren
async function openUpload(e: MouseEvent) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
if (navigating.value) return
navigating.value = true
indicator.start()
// Der angeklickte Tile bekommt sofort den view-transition-name — der NuxtLink
// navigiert eigenständig, also muss der Name gesetzt sein, bevor der Browser
// den alten Zustand für die Transition fotografiert
lastViewedId.value = props.upload.id
// Bekannte Mediendaten, damit die Thread-Seite sofort ein benanntes
// Element rendern kann (View-Transition-Ziel vor Eintreffen der Daten)
pendingMedia.value = {
id: props.upload.id,
type: props.upload.type,
url: props.upload.url,
thumbUrl: props.upload.thumbUrl ?? null,
}
try {
const threadFetch = $fetch(`/api/uploads/${props.upload.id}`)
const mediaPreload = (async () => {
if (props.upload.type === 'image' && props.upload.url) {
const img = new Image()
img.src = props.upload.url
try {
await Promise.race([img.decode(), new Promise(r => setTimeout(r, 2500))])
}
catch {
// ignore
}
}
else if (props.upload.type === 'video' && props.upload.url) {
await new Promise<void>((resolve) => {
const v = document.createElement('video')
v.preload = 'auto'
v.muted = true
v.onloadeddata = () => resolve()
v.onerror = () => resolve()
v.src = props.upload.url
setTimeout(resolve, 2500)
})
}
})()
const minDelay = new Promise(r => setTimeout(r, 600))
await Promise.all([threadFetch, mediaPreload, minDelay])
}
catch {
indicator.finish({ error: true })
navigating.value = false
toast.error('Thread konnte nicht geladen werden')
return
}
// Die eigentliche Navigation macht der NuxtLink bereits beim Klick —
// dieser Push ist nur noch Fallback und bei gleicher Route ein No-Op
await router.push(`/thread/${props.upload.id}`)
navigating.value = false
}
</script>
<template>
<NuxtLink
:to="`/thread/${upload.id}`"
class="upload-tile group block"
:class="[spanClass, navigating ? 'opacity-80' : '']"
@click="openUpload"
>
<div
class="upload-tile-media relative h-full w-full overflow-hidden rounded-md"
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
>
<NuxtImg
v-if="thumbSrc"
:src="thumbSrc"
:alt="upload.userName ?? 'Upload'"
width="400"
height="400"
fit="cover"
loading="lazy"
:placeholder="canPlaceholder || undefined"
class="h-full w-full object-cover"
/>
<video
v-else-if="upload.type === 'video'"
:src="upload.url"
preload="metadata"
muted
playsinline
class="h-full w-full object-cover"
/>
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
<Icon name="ph:music-note-fill" v-if="upload.type === 'audio'" class="size-8 text-muted-foreground" />
<Icon name="ph:link-fill" v-else class="size-8 text-muted-foreground" />
</div>
<span
v-if="hasNotification"
class="absolute top-1.5 right-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-accent text-accent-foreground shadow"
>
<Icon name="ph:bell-fill" class="size-3.5" />
</span>
<div class="absolute inset-0 bg-primary/30 opacity-0 transition-opacity group-hover:opacity-100" />
<div class="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 via-black/20 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex w-full items-end justify-between gap-2 text-xs text-white">
<UserHoverCard
v-if="upload.userId != null"
:user-id="upload.userId"
:user-name="upload.userName ?? ''"
:user-color="upload.userColor"
:user-avatar-url="upload.userAvatarUrl"
class="min-w-0"
>
<span class="truncate">{{ upload.userName }}</span>
</UserHoverCard>
<span v-else class="truncate">{{ upload.userName }}</span>
<span v-if="upload.commentCount !== undefined" class="flex shrink-0 items-center gap-1">
<Icon name="ph:chat-fill" class="size-3.5" />
{{ upload.commentCount }}
</span>
</div>
</div>
</div>
</NuxtLink>
</template>
+98
View File
@@ -0,0 +1,98 @@
<script setup lang="ts">
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import type { CardEffectKey } from '~~/shared/utils/cardEffects'
import { isCardEffect } from '~~/shared/utils/cardEffects'
import CardBackgroundEffect from '@/components/effects/CardBackgroundEffect.vue'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CardVignette from '@/components/effects/CardVignette.vue'
const props = defineProps<{
userId: number
userName: string
userColor?: string | null
userAvatarUrl?: string | null
}>()
const { summaries, ensureSummary } = useUserSummaries()
const summary = computed(() => summaries.value[props.userId])
const avatar = computed(() => summary.value?.avatarUrl ?? props.userAvatarUrl ?? null)
const color = computed(() => summary.value?.color ?? props.userColor ?? null)
const effect = computed<CardEffectKey | null>(() => {
const key = summary.value?.cardEffect
return key && isCardEffect(key) ? key : null
})
const borderEffect = computed<CardBorderEffectKey | null>(() => {
const key = summary.value?.cardBorderEffect
return key && isCardBorderEffect(key) ? key : null
})
function onOpen(open: boolean) {
if (open) ensureSummary(props.userId)
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
}
</script>
<template>
<HoverCard :open-delay="200" :close-delay="150" @update:open="onOpen">
<HoverCardTrigger as-child>
<slot />
</HoverCardTrigger>
<HoverCardContent class="relative w-72 overflow-hidden rounded-md">
<div v-if="effect" class="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [clip-path:inset(0_round_var(--radius-md))]">
<CardBackgroundEffect :effect="effect" :color="summary?.cardEffectColor ?? color ?? '#6366f1'" />
<div class="absolute inset-0 bg-popover/60" />
<CardVignette v-if="summary?.cardVignette" />
</div>
<div class="relative flex items-start gap-3">
<NuxtImg
v-if="avatar"
:src="avatar"
:alt="userName"
width="56"
height="56"
fit="cover"
class="size-14 shrink-0 rounded-md"
/>
<span
v-else
class="flex size-14 shrink-0 items-center justify-center rounded-md text-lg font-medium"
:style="color ? { backgroundColor: color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ userName.slice(0, 2).toUpperCase() }}</span>
<div class="min-w-0">
<NuxtLink
:to="`/user/${userId}`"
class="block truncate text-base font-medium hover:underline"
:style="color ? { color } : undefined"
>{{ summary?.name ?? userName }}</NuxtLink>
<p class="text-xs text-muted-foreground">
#{{ userId }}
</p>
<p v-if="summary" class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<Icon name="ph:calendar-fill" class="size-3" />
seit {{ formatDate(summary.createdAt) }}
</p>
</div>
</div>
<div v-if="summary" class="relative mt-3 flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1.5">
<Icon name="ph:cloud-arrow-up-fill" class="size-3.5" />
{{ summary.stats.uploads }} Uploads
</span>
<span class="flex items-center gap-1.5">
<Icon name="ph:chat-fill" class="size-3.5" />
{{ summary.stats.comments }} Kommentare
</span>
</div>
<CardBorderEffect v-if="borderEffect" :effect="borderEffect" :color="summary?.cardBorderEffectColor ?? color ?? '#6366f1'" />
</HoverCardContent>
</HoverCard>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type Plyr from 'plyr'
import 'plyr/dist/plyr.css'
defineProps<{
src: string
}>()
const videoEl = ref<HTMLVideoElement | null>(null)
const aspect = ref<number | null>(null)
let player: Plyr | null = null
onMounted(async () => {
if (!videoEl.value) return
const { default: PlyrClass } = await import('plyr')
player = new PlyrClass(videoEl.value, {
disableContextMenu: false,
storage: { enabled: true, key: '4brett-player' },
})
})
onBeforeUnmount(() => {
player?.destroy()
player = null
})
function onLoadedMetadata() {
const v = videoEl.value
if (v?.videoWidth && v.videoHeight) {
aspect.value = v.videoWidth / v.videoHeight
}
}
</script>
<template>
<div
class="video-player mx-auto w-full max-w-4xl"
:style="aspect ? { maxWidth: `min(100%, 56rem, calc(80vh * ${aspect}))` } : undefined"
>
<video
ref="videoEl"
:src="src"
playsinline
controls
class="rounded-md"
@loadedmetadata="onLoadedMetadata"
/>
</div>
</template>
<style scoped>
.video-player {
--plyr-color-main: var(--accent);
--plyr-border-radius: var(--radius-md);
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { useMediaControls } from '@vueuse/core'
defineProps<{
src: string
name: string
}>()
const audioRef = ref<HTMLAudioElement>()
const { playing, currentTime, duration, volume, muted } = useMediaControls(audioRef)
function toggle() {
playing.value = !playing.value
}
function seek(event: Event) {
currentTime.value = Number((event.target as HTMLInputElement).value)
}
function formatTime(sec: number) {
if (!Number.isFinite(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
</script>
<template>
<div
class="flex w-full max-w-md items-center gap-2.5 rounded-md border bg-muted/50 px-3 py-2"
@click.stop
>
<audio ref="audioRef" :src="src" preload="metadata" class="hidden" />
<button
type="button"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
:aria-label="playing ? 'Pause' : 'Abspielen'"
@click="toggle"
>
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
<Icon name="ph:play-fill" v-else class="size-4" />
</button>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(currentTime) }}</span>
<input
type="range"
min="0"
:max="Number.isFinite(duration) ? duration : 0"
step="0.1"
:value="currentTime"
aria-label="Wiedergabeposition"
class="min-w-0 flex-1 cursor-pointer accent-primary"
@input="seek"
>
<span class="shrink-0 text-xs tabular-nums text-muted-foreground">{{ formatTime(duration) }}</span>
<button
type="button"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
:aria-label="muted ? 'Ton an' : 'Stumm'"
@click="muted = !muted"
>
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-3.5" />
<Icon name="ph:speaker-high-fill" v-else class="size-3.5" />
</button>
<input
v-model.number="volume"
type="range"
min="0"
max="1"
step="0.05"
aria-label="Lautstärke"
class="w-14 min-w-0 shrink-0 cursor-pointer accent-primary"
:class="muted && 'opacity-40'"
>
<a
:href="src"
:download="name"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="Herunterladen"
title="Herunterladen"
>
<Icon name="ph:download-fill" class="size-3.5" />
</a>
</div>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { CardEffectKey } from '~~/shared/utils/cardEffects'
import EffectAcidSquares from '@/components/effects/EffectAcidSquares.vue'
import EffectAurora from '@/components/effects/EffectAurora.vue'
import EffectCrtWarp from '@/components/effects/EffectCrtWarp.vue'
import EffectDither from '@/components/effects/EffectDither.vue'
import EffectGradientWaves from '@/components/effects/EffectGradientWaves.vue'
import EffectLetterGlitch from '@/components/effects/EffectLetterGlitch.vue'
import EffectLineWaves from '@/components/effects/EffectLineWaves.vue'
import EffectParticles from '@/components/effects/EffectParticles.vue'
import EffectPlasma from '@/components/effects/EffectPlasma.vue'
import EffectScanner from '@/components/effects/EffectScanner.vue'
import EffectShapeGrid from '@/components/effects/EffectShapeGrid.vue'
import EffectSilk from '@/components/effects/EffectSilk.vue'
import EffectSoftAurora from '@/components/effects/EffectSoftAurora.vue'
import EffectWaves from '@/components/effects/EffectWaves.vue'
const props = defineProps<{
effect: CardEffectKey
color: string
}>()
const COMPONENTS = {
'gradient-waves': EffectGradientWaves,
'aurora': EffectAurora,
'particles': EffectParticles,
'letter-glitch': EffectLetterGlitch,
'waves': EffectWaves,
'scanner': EffectScanner,
'acid-squares': EffectAcidSquares,
'crt-warp': EffectCrtWarp,
'dither': EffectDither,
'soft-aurora': EffectSoftAurora,
'silk': EffectSilk,
'shape-grid': EffectShapeGrid,
'plasma': EffectPlasma,
'line-waves': EffectLineWaves,
} as const
const component = computed(() => COMPONENTS[props.effect] ?? null)
</script>
<template>
<component :is="component" v-if="component" :color="color" />
</template>
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
import { hexToRgbTuple } from '@/lib/color'
// Electric-Variante portiert aus Vue Bits (DavidHDev/vue-bits, ElectricBorder)
const props = withDefaults(defineProps<{
effect: CardBorderEffectKey
color?: string
}>(), {
color: '#6366f1',
})
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
function random(x: number): number {
return (Math.sin(x * 12.9898) * 43758.5453) % 1
}
function noise2D(x: number, y: number): number {
const i = Math.floor(x)
const j = Math.floor(y)
const fx = x - i
const fy = y - j
const a = random(i + j * 57)
const b = random(i + 1 + j * 57)
const c = random(i + (j + 1) * 57)
const d = random(i + 1 + (j + 1) * 57)
const ux = fx * fx * (3 - 2 * fx)
const uy = fy * fy * (3 - 2 * fy)
return a * (1 - ux) * (1 - uy) + b * ux * (1 - uy) + c * (1 - ux) * uy + d * ux * uy
}
function octavedNoise(x: number, time: number, seed: number): number {
let y = 0
let amplitude = 0.12
let frequency = 10
for (let i = 0; i < 10; i++) {
y += amplitude * noise2D(frequency * x + seed * 100, time * frequency * 0.3)
frequency *= 1.6
amplitude *= 0.7
}
return y
}
function roundedRectPoint(t: number, w: number, h: number, r: number): { x: number, y: number } {
const sw = w - 2 * r
const sh = h - 2 * r
const arc = (Math.PI * r) / 2
const total = 2 * sw + 2 * sh + 4 * arc
let d = t * total
if (d <= sw) return { x: r + d, y: 0 }
d -= sw
if (d <= arc) {
const a = -Math.PI / 2 + (d / arc) * (Math.PI / 2)
return { x: w - r + r * Math.cos(a), y: r + r * Math.sin(a) }
}
d -= arc
if (d <= sh) return { x: w, y: r + d }
d -= sh
if (d <= arc) {
const a = (d / arc) * (Math.PI / 2)
return { x: w - r + r * Math.cos(a), y: h - r + r * Math.sin(a) }
}
d -= arc
if (d <= sw) return { x: w - r - d, y: h }
d -= sw
if (d <= arc) {
const a = Math.PI / 2 + (d / arc) * (Math.PI / 2)
return { x: r + r * Math.cos(a), y: h - r + r * Math.sin(a) }
}
d -= arc
if (d <= sh) return { x: 0, y: h - r - d }
d -= sh
const a = Math.PI + (d / arc) * (Math.PI / 2)
return { x: r + r * Math.cos(a), y: r + r * Math.sin(a) }
}
let cleanup: (() => void) | null = null
onMounted(() => {
if (props.effect !== 'electric') return
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let width = 0
let height = 0
let radius = 0
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const updateSize = () => {
const rect = container.getBoundingClientRect()
width = rect.width
height = rect.height
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
radius = parseFloat(getComputedStyle(container).borderRadius) || 0
}
updateSize()
const ro = new ResizeObserver(updateSize)
ro.observe(container)
let raf = 0
let time = 0
let last = 0
const draw = (now: number) => {
time += ((now - last) / 1000) * 1.2
last = now
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
ctx.strokeStyle = props.color
ctx.lineWidth = 1.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
const inset = 1
const w = width - inset * 2
const h = height - inset * 2
const r = Math.max(0, radius - inset)
const perimeter = 2 * (w + h) + 2 * Math.PI * r
const samples = Math.floor(perimeter / 2)
ctx.beginPath()
for (let i = 0; i <= samples; i++) {
const p = i / samples
const pt = roundedRectPoint(p, w, h, r)
const nx = octavedNoise(p * 8, time, 0) * 14
const ny = octavedNoise(p * 8, time, 1) * 14
if (i === 0) ctx.moveTo(inset + pt.x + nx, inset + pt.y + ny)
else ctx.lineTo(inset + pt.x + nx, inset + pt.y + ny)
}
ctx.closePath()
ctx.stroke()
raf = requestAnimationFrame(draw)
}
raf = requestAnimationFrame(draw)
cleanup = () => {
cancelAnimationFrame(raf)
ro.disconnect()
}
})
onBeforeUnmount(() => {
cleanup?.()
cleanup = null
})
const rgb = computed(() => hexToRgbTuple(props.color).map(v => Math.round(v * 255)).join(' '))
const cssVars = computed<CSSProperties>(() => ({ '--be-color': `rgb(${rgb.value})` }) as CSSProperties)
</script>
<template>
<div ref="containerRef" class="pointer-events-none absolute inset-0 rounded-[inherit]" :style="cssVars">
<canvas v-if="effect === 'electric'" ref="canvasRef" class="absolute inset-0" />
<div v-else-if="effect === 'beam'" class="border-beam absolute inset-0 rounded-[inherit]" />
<div v-else-if="effect === 'pulse'" class="border-pulse absolute inset-0 rounded-[inherit]" />
<div v-else-if="effect === 'rainbow'" class="border-rainbow absolute inset-0 rounded-[inherit]" />
</div>
</template>
<style scoped>
@property --beam-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.border-beam {
border: 2px solid transparent;
background: conic-gradient(from var(--beam-angle), transparent 60%, var(--be-color) 80%, transparent 100%) border-box;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
animation: be-rotate 3s linear infinite;
}
.border-pulse {
border: 2px solid var(--be-color);
box-shadow: 0 0 12px 1px var(--be-color);
animation: be-pulse 2s ease-in-out infinite;
}
.border-rainbow {
border: 2px solid transparent;
background: conic-gradient(from var(--beam-angle), var(--be-color), #ffffff, var(--be-color)) border-box;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
animation: be-rotate 4s linear infinite;
}
@keyframes be-rotate {
to { --beam-angle: 360deg; }
}
@keyframes be-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
</style>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
// Optionale Vignette über Karten-Hintergrundeffekten: dunkelt die Ränder
// zur Surface-Farbe hin ab, damit darüberliegender Text lesbar bleibt.
// `surface` muss die CSS-Farbe der Kartenfläche sein (popover/card/background),
// damit die Kante optisch in die Karte übergeht.
withDefaults(defineProps<{ surface?: string }>(), {
surface: 'var(--color-popover)',
})
</script>
<template>
<div
class="pointer-events-none absolute inset-0 rounded-[inherit]"
:style="{ background: `radial-gradient(ellipse at center, transparent 25%, ${surface} 92%)` }"
/>
</template>
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
// Blur-Post-Pass entfernt (blur: 0 war Standard)
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `#version 300 es
precision highp float;
uniform float iTime;
uniform float uSpeed;
uniform float uWaveDepth;
uniform float uZoom;
uniform float uDensity;
uniform float uSpread;
uniform float uStepSize;
uniform float uGlow;
uniform float uExposure;
uniform float uColorShift;
uniform float uContrast;
uniform float uBrightness;
uniform float uOpacity;
uniform float uSteps;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
uniform vec2 uMouse;
uniform float uMouseStrength;
uniform float uMouseRadius;
uniform float uMouseActive;
uniform float uGrainIntensity;
uniform vec2 uCardRes;
uniform float uRadius;
out vec4 fragColor;
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
vec2 frag = gl_FragCoord.xy;
float zoom = max(uZoom, 0.05);
// Pixelkonsistent: Referenzhöhe 180px, damit die Zellen in flachen
// Containern ihre reale Pixelgröße behalten statt zu stauchen
vec2 ndc = (2.0 * frag - uCardRes.xy) / 180.0;
vec2 dir = ndc * (0.5 / zoom);
vec2 mouseNdc = uMouse * (uCardRes / 360.0);
float mr = max(uMouseRadius, 0.01);
vec2 md = ndc - mouseNdc;
float dent = exp(-dot(md, md) / (mr * mr)) * (3.0 * uMouseStrength * uMouseActive);
float travel = sin(iTime * uSpeed) * uWaveDepth;
float density = max(uDensity, 1.0);
float spread = clamp(uSpread, 0.05, 0.6);
float stepSize = max(uStepSize, 0.0005);
float glowGain = max(uGlow, 0.0);
vec3 tOffset = vec3(0.0, dent, travel);
vec3 p = vec3(0.0);
float s = 0.0;
float glow = 0.0;
for (int i = 0; i < 64; i++) {
if (float(i) >= uSteps) break;
p += vec3(dir * s, s);
vec3 q = p + tOffset;
s += density - length(q.xz) + length(ceil(q).xy);
s = stepSize + abs(s) * spread;
glow += glowGain / s;
}
float e = glow / max(uExposure, 1.0);
float shimmer = 0.5 + 0.5 * dot(cos(iTime * uColorShift + p), vec3(0.3333));
float v = tanh(e * uBrightness * mix(0.7, 1.05, shimmer));
v = clamp((v - 0.5) * uContrast + 0.5, 0.0, 1.0);
vec3 col = mix(uColor1, uColor2, smoothstep(0.0, 0.55, v));
col = mix(col, uColor3, smoothstep(0.55, 1.0, v));
col *= v;
float a = clamp(v, 0.0, 1.0) * uOpacity;
vec3 outRgb = col;
if (a > 0.01) {
float gv = (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233)) + iTime) * 43758.5453) - 0.5) * uGrainIntensity;
outRgb = clamp(outRgb + gv, 0.0, 1.0);
a = clamp(a + gv, 0.0, 1.0);
}
a *= cardMask(frag);
fragColor = vec4(outRgb * a, a);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
uniforms: {
iTime: { value: 0 },
uSpeed: { value: 0.7 },
uWaveDepth: { value: 1 },
uZoom: { value: 1.3 },
uDensity: { value: 10.0 },
uSpread: { value: 0.3 },
uStepSize: { value: 0.002 },
uGlow: { value: 1.0 },
uExposure: { value: 2700 },
uColorShift: { value: 0 },
uContrast: { value: 1 },
uBrightness: { value: 1.0 },
uOpacity: { value: 1.0 },
uSteps: { value: 32 },
uColor1: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.35))) },
uColor2: { value: new Float32Array(hexToRgbTuple(props.color)) },
uColor3: { value: new Float32Array([1, 1, 1]) },
uMouse: { value: new Float32Array([0, 0]) },
uMouseStrength: { value: 0.1 },
uMouseRadius: { value: 0.35 },
uMouseActive: { value: 0 },
uGrainIntensity: { value: 0.05 },
},
color: () => props.color,
colorUniforms: { uColor1: -0.35, uColor2: 0 },
frame: ({ t, program, mouse }) => {
const u = program.uniforms
u.iTime.value = t * 0.001
const m = u.uMouse.value as Float32Array
m[0] = mouse.x * 2 - 1
m[1] = mouse.y * 2 - 1
u.uMouseActive.value = mouse.active
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+223
View File
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { Color, Mesh, Program, Renderer, Triangle } from 'ogl'
import { shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `#version 300 es
precision highp float;
uniform float uTime;
uniform float uAmplitude;
uniform vec3 uColorStops[3];
uniform vec2 uResolution;
uniform float uBlend;
uniform float uRadius;
out vec4 fragColor;
vec3 permute(vec3 x) {
return mod(((x * 34.0) + 1.0) * x, 289.0);
}
float snoise(vec2 v){
const vec4 C = vec4(
0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439
);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute(
permute(i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0)
);
vec3 m = max(
0.5 - vec3(
dot(x0, x0),
dot(x12.xy, x12.xy),
dot(x12.zw, x12.zw)
),
0.0
);
m = m * m;
m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
struct ColorStop {
vec3 color;
float position;
};
#define COLOR_RAMP(colors, factor, finalColor) { \\
int index = 0; \\
for (int i = 0; i < 2; i++) { \\
ColorStop currentColor = colors[i]; \\
bool isInBetween = currentColor.position <= factor; \\
index = int(mix(float(index), float(i), float(isInBetween))); \\
} \\
ColorStop currentColor = colors[index]; \\
ColorStop nextColor = colors[index + 1]; \\
float range = nextColor.position - currentColor.position; \\
float lerpFactor = (factor - currentColor.position) / range; \\
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \\
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
ColorStop colors[3];
colors[0] = ColorStop(uColorStops[0], 0.0);
colors[1] = ColorStop(uColorStops[1], 0.5);
colors[2] = ColorStop(uColorStops[2], 1.0);
vec3 rampColor;
COLOR_RAMP(colors, uv.x, rampColor);
// Noise x-Koordinate pixelkonsistent (~2 Einheiten pro 320px), damit
// die Wellen in schmalen/breiten Karten nicht gestaucht/gestreckt wirken
float height = snoise(vec2(gl_FragCoord.x / 160.0 + uTime * 0.1, uTime * 0.25)) * 0.5 * uAmplitude;
height = exp(height);
height = (uv.y * 2.0 - height + 0.2);
float intensity = 0.6 * height;
float midPoint = 0.20;
float auroraAlpha = smoothstep(midPoint - uBlend * 0.5, midPoint + uBlend * 0.5, intensity);
vec3 auroraColor = intensity * rampColor;
// Rounded-Rect-Maske direkt im Shader — zuverlässig auch wenn der
// Compositor den Canvas-Layer nicht an CSS-Clips bindet (Waterfox)
vec2 p = gl_FragCoord.xy - uResolution * 0.5;
vec2 hb = uResolution * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
float rm = 1.0 - smoothstep(-0.75, 0.75, rd);
auroraAlpha *= rm;
fragColor = vec4(auroraColor * auroraAlpha, auroraAlpha);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
let animateId = 0
let teardown: (() => void) | null = null
onMounted(() => {
const ctn = ctnDom.value
if (!ctn) return
const renderer = new Renderer({ alpha: true, premultipliedAlpha: true, antialias: true })
const gl = renderer.gl
gl.clearColor(0, 0, 0, 0)
gl.enable(gl.BLEND)
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
// WebGL-Canvas bleibt detached: Waterfox/Firefox zeichnet WebGL-Layer nach
// Scrollen teils an veralteter Position und ignoriert CSS-Clips — stattdessen
// wird pro Frame in einen normalen 2D-Canvas geblittet
const outCanvas = document.createElement('canvas')
outCanvas.className = 'absolute inset-0 size-full rounded-[inherit]'
const outCtx = outCanvas.getContext('2d')
ctn.appendChild(outCanvas)
const geometry = new Triangle(gl)
if (geometry.attributes.uv) {
delete geometry.attributes.uv
}
const colorStops = [shadeHex(props.color, -0.5), props.color, shadeHex(props.color, 0.4)]
.map((hex) => {
const c = new Color(hex)
return [c.r, c.g, c.b]
})
const program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uAmplitude: { value: 1.0 },
uColorStops: { value: colorStops },
uResolution: { value: [ctn.offsetWidth, ctn.offsetHeight] },
uBlend: { value: 0.5 },
uRadius: { value: 0 },
},
})
const mesh = new Mesh(gl, { geometry, program })
// Farbwechsel live übernehmen (Settings-Vorschau)
watch(() => props.color, (c) => {
program.uniforms.uColorStops!.value = [shadeHex(c, -0.5), c, shadeHex(c, 0.4)]
.map((hex) => {
const col = new Color(hex)
return [col.r, col.g, col.b]
})
})
const resizeHandler = () => {
renderer.setSize(ctn.offsetWidth, ctn.offsetHeight)
program.uniforms.uResolution.value = [gl.drawingBufferWidth, gl.drawingBufferHeight]
const cssRadius = parseFloat(getComputedStyle(ctn).borderTopLeftRadius) || 0
program.uniforms.uRadius.value = ctn.offsetWidth > 0
? cssRadius * (gl.drawingBufferWidth / ctn.offsetWidth)
: 0
outCanvas.width = gl.drawingBufferWidth
outCanvas.height = gl.drawingBufferHeight
}
const ro = new ResizeObserver(resizeHandler)
ro.observe(ctn)
resizeHandler()
const update = (t: number) => {
animateId = requestAnimationFrame(update)
program.uniforms.uTime.value = t * 0.01 * 0.1
renderer.render({ scene: mesh })
outCtx?.clearRect(0, 0, outCanvas.width, outCanvas.height)
outCtx?.drawImage(gl.canvas, 0, 0, outCanvas.width, outCanvas.height)
}
animateId = requestAnimationFrame(update)
teardown = () => {
cancelAnimationFrame(animateId)
ro.disconnect()
if (outCanvas.parentNode === ctn) ctn.removeChild(outCanvas)
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
})
onUnmounted(() => {
teardown?.()
teardown = null
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), von three.js auf ogl umgeschrieben,
// vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform float uSpeed;
uniform vec3 uColor;
uniform vec3 uBackgroundColor;
uniform float uCurvature;
uniform float uScanlineStrength;
uniform float uScanlineFrequency;
uniform float uWaveAmplitude;
uniform float uWaveFrequency;
uniform float uBloom;
uniform float uBloomRadius;
uniform float uNoise;
uniform float uVignette;
uniform float uBrightness;
uniform float uPixelation;
uniform float uRgbShift;
uniform vec2 uPointer;
uniform float uMouseStrength;
uniform vec2 uCardRes;
uniform float uRadius;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
vec2 crtCurve(vec2 uv, float radius) {
vec2 p = (uv - 0.5) * 2.0;
float safeRadius = max(radius, 1.415);
float cornerScale = safeRadius / sqrt(max(safeRadius * safeRadius - 2.0, 0.001));
p = safeRadius * p / sqrt(max(safeRadius * safeRadius - dot(p, p), 0.001));
p /= cornerScale;
return p * 0.5 + 0.5;
}
float referencePlasma(vec2 uv, float t) {
float frequencyScale = max(uWaveFrequency / 2.2, 0.001);
uv = (uv - 0.5) * frequencyScale + 0.5;
float scanline = 0.5 - 0.5 * cos(uv.y * 3.14159265 * uScanlineFrequency);
scanline = mix(1.0, scanline, uScanlineStrength);
// Pixelkonsistente Zellen (~16px) statt fixem 80x24-Raster,
// damit das CRT-Pixeln in flachen Containern nicht staucht
vec2 cells = max(round(uCardRes / 16.0), vec2(4.0));
uv *= cells;
uv = ceil(uv);
uv /= cells;
float amplitude = uWaveAmplitude / 0.28;
float field = 0.0;
field += 0.7 * sin(0.5 * uv.x + t / 5.0);
field += 3.0 * sin(1.6 * uv.y + t / 5.0);
field += sin(10.0 * (uv.y * sin(t / 2.0) + uv.x * cos(t / 5.0)) + t / 2.0);
float cx = uv.x + 0.5 * sin(t / 2.0);
float cy = uv.y + 0.5 * cos(t / 4.0);
field += 0.4 * sin(sqrt(100.0 * cx * cx + 100.0 * cy * cy + 1.0) + t);
field += 0.9 * sin(sqrt(75.0 * cx * cx + 25.0 * cy * cy + 1.0) + t);
field -= 1.4 * sin(sqrt(256.0 * cx * cx + 25.0 * cy * cy + 1.0) + t);
field += 0.3 * sin(0.5 * uv.y + uv.x + sin(t));
return scanline * floor(3.0 * (0.5 + 0.499 * sin(field * amplitude))) / 3.0;
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
vec2 uv = vUv;
if (uPixelation > 1.001) {
vec2 cells = max(uCardRes / uPixelation, vec2(1.0));
uv = (floor(uv * cells) + 0.5) / cells;
}
float curveRadius = 1.1 + 0.42 / max(uCurvature, 0.001);
curveRadius *= exp(-uPointer.y * uMouseStrength * 0.4);
vec2 curvedUv = crtCurve(uv, curveRadius);
curvedUv.x -= uPointer.x * uMouseStrength * 0.035;
float t = uTime * uSpeed;
float signal = referencePlasma(curvedUv, t);
float radius = 0.01 * uBloomRadius;
float glow = signal * 0.2;
glow += referencePlasma(curvedUv + vec2(radius, 0.0), t) * 0.12;
glow += referencePlasma(curvedUv - vec2(radius, 0.0), t) * 0.12;
glow += referencePlasma(curvedUv + vec2(0.0, radius), t) * 0.12;
glow += referencePlasma(curvedUv - vec2(0.0, radius), t) * 0.12;
glow += referencePlasma(curvedUv + vec2(radius), t) * 0.08;
glow += referencePlasma(curvedUv - vec2(radius), t) * 0.08;
glow += referencePlasma(curvedUv + vec2(radius, -radius), t) * 0.08;
glow += referencePlasma(curvedUv + vec2(-radius, radius), t) * 0.08;
float redSignal = referencePlasma(curvedUv + vec2(uRgbShift, 0.0), t);
float blueSignal = referencePlasma(curvedUv - vec2(uRgbShift, 0.0), t);
vec3 channelSignal = vec3(redSignal, signal, blueSignal);
vec3 waveColor = uColor * (0.3 + signal * 0.7 + glow * uBloom * 0.65);
waveColor += (channelSignal - signal) * 0.42;
float edge = clamp(1.0 - dot(vUv - 0.5, vUv - 0.5) * 2.0, 0.0, 1.0);
float edgeFade = mix(1.0, smoothstep(0.0, 1.0, edge), uVignette);
float waveMask = clamp(signal * 0.82 + glow * 0.52, 0.0, 1.0) * edgeFade;
waveColor = max(waveColor * uBrightness, vec3(0.0));
float a = clamp(waveMask, 0.0, 1.0) * cardMask(gl_FragCoord.xy);
vec3 col = waveColor;
if (a > 0.01) {
float grain = (hash21(gl_FragCoord.xy + vec2(fract(uTime) * 173.0)) - 0.5) * uNoise;
col = max(col + grain, vec3(0.0));
}
gl_FragColor = vec4(col * a, a);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
let elapsed = 0
let lastT = 0
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
uniforms: {
uTime: { value: 0 },
uSpeed: { value: 0.5 },
uColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
uBackgroundColor: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.93))) },
uCurvature: { value: 0.25 },
uScanlineStrength: { value: 0.25 },
uScanlineFrequency: { value: 200 },
uWaveAmplitude: { value: 0.3 },
uWaveFrequency: { value: 2.5 },
uBloom: { value: 1.5 },
uBloomRadius: { value: 1 },
uNoise: { value: 0.1 },
uVignette: { value: 0 },
uBrightness: { value: 1.25 },
uPixelation: { value: 1 },
uRgbShift: { value: 0.015 },
uPointer: { value: new Float32Array([0, 0]) },
uMouseStrength: { value: 0.5 },
},
color: () => props.color,
colorUniforms: { uColor: 0, uBackgroundColor: -0.93 },
frame: ({ t, program, mouse }) => {
// fps-Cap ~30 wie im Original (CRT-Flackern)
const u = program.uniforms
const delta = Math.min((t - lastT) * 0.001, 0.1)
lastT = t
elapsed += delta
u.uTime.value = elapsed
const p = u.uPointer.value as Float32Array
p[0] = (mouse.x * 2 - 1) * mouse.active
p[1] = (mouse.y * 2 - 1) * mouse.active
// Scanline-Dichte in Pixel-Pitch (eine Linie alle ~6 Buffer-Pixel)
// statt fixer Zeilenzahl — flache Karten sonst subpixel-gestaucht
const res = u.uCardRes!.value as Float32Array
u.uScanlineFrequency!.value = res[1] / 6
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { hexToRgbTuple } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `
precision highp float;
uniform float time;
uniform float waveSpeed;
uniform float waveFrequency;
uniform float waveAmplitude;
uniform vec3 waveColor;
uniform vec2 mousePos;
uniform float mouseActive;
uniform float mouseRadius;
uniform float colorNum;
uniform float pixelSize;
uniform vec2 uCardRes;
uniform float uRadius;
varying vec2 vUv;
vec4 mod289(vec4 x) { return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
vec2 fade(vec2 t) { return t*t*t*(t*(t*6.0-15.0)+10.0); }
float cnoise(vec2 P) {
vec4 Pi = floor(P.xyxy) + vec4(0.0,0.0,1.0,1.0);
vec4 Pf = fract(P.xyxy) - vec4(0.0,0.0,1.0,1.0);
Pi = mod289(Pi);
vec4 ix = Pi.xzxz;
vec4 iy = Pi.yyww;
vec4 fx = Pf.xzxz;
vec4 fy = Pf.yyww;
vec4 i = permute(permute(ix) + iy);
vec4 gx = fract(i * (1.0/41.0)) * 2.0 - 1.0;
vec4 gy = abs(gx) - 0.5;
vec4 tx = floor(gx + 0.5);
gx = gx - tx;
vec2 g00 = vec2(gx.x, gy.x);
vec2 g10 = vec2(gx.y, gy.y);
vec2 g01 = vec2(gx.z, gy.z);
vec2 g11 = vec2(gx.w, gy.w);
vec4 norm = taylorInvSqrt(vec4(dot(g00,g00), dot(g01,g01), dot(g10,g10), dot(g11,g11)));
g00 *= norm.x; g01 *= norm.y; g10 *= norm.z; g11 *= norm.w;
float n00 = dot(g00, vec2(fx.x, fy.x));
float n10 = dot(g10, vec2(fx.y, fy.y));
float n01 = dot(g01, vec2(fx.z, fy.z));
float n11 = dot(g11, vec2(fx.w, fy.w));
vec2 fade_xy = fade(Pf.xy);
float n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
return 2.3 * mix(n_x.x, n_x.y, fade_xy.y);
}
const int OCTAVES = 8;
float fbm(vec2 p) {
float value = 0.0;
float amp = 1.0;
float freq = waveFrequency;
for (int i = 0; i < OCTAVES; i++) {
value += amp * abs(cnoise(p));
p *= freq;
amp *= waveAmplitude;
}
return value;
}
float pattern(vec2 p) {
vec2 p2 = p - time * waveSpeed;
return fbm(p - fbm(p + fbm(p2)));
}
float getBayerValue(int x, int y) {
if (y == 0) {
if (x == 0) return 0.0/64.0;
if (x == 1) return 48.0/64.0;
if (x == 2) return 12.0/64.0;
if (x == 3) return 60.0/64.0;
if (x == 4) return 3.0/64.0;
if (x == 5) return 51.0/64.0;
if (x == 6) return 15.0/64.0;
if (x == 7) return 63.0/64.0;
} else if (y == 1) {
if (x == 0) return 32.0/64.0;
if (x == 1) return 16.0/64.0;
if (x == 2) return 44.0/64.0;
if (x == 3) return 28.0/64.0;
if (x == 4) return 35.0/64.0;
if (x == 5) return 19.0/64.0;
if (x == 6) return 47.0/64.0;
if (x == 7) return 31.0/64.0;
} else if (y == 2) {
if (x == 0) return 8.0/64.0;
if (x == 1) return 56.0/64.0;
if (x == 2) return 4.0/64.0;
if (x == 3) return 52.0/64.0;
if (x == 4) return 11.0/64.0;
if (x == 5) return 59.0/64.0;
if (x == 6) return 7.0/64.0;
if (x == 7) return 55.0/64.0;
} else if (y == 3) {
if (x == 0) return 40.0/64.0;
if (x == 1) return 24.0/64.0;
if (x == 2) return 36.0/64.0;
if (x == 3) return 20.0/64.0;
if (x == 4) return 43.0/64.0;
if (x == 5) return 27.0/64.0;
if (x == 6) return 39.0/64.0;
if (x == 7) return 23.0/64.0;
} else if (y == 4) {
if (x == 0) return 2.0/64.0;
if (x == 1) return 50.0/64.0;
if (x == 2) return 14.0/64.0;
if (x == 3) return 62.0/64.0;
if (x == 4) return 1.0/64.0;
if (x == 5) return 49.0/64.0;
if (x == 6) return 13.0/64.0;
if (x == 7) return 61.0/64.0;
} else if (y == 5) {
if (x == 0) return 34.0/64.0;
if (x == 1) return 18.0/64.0;
if (x == 2) return 46.0/64.0;
if (x == 3) return 30.0/64.0;
if (x == 4) return 33.0/64.0;
if (x == 5) return 17.0/64.0;
if (x == 6) return 45.0/64.0;
if (x == 7) return 29.0/64.0;
} else if (y == 6) {
if (x == 0) return 10.0/64.0;
if (x == 1) return 58.0/64.0;
if (x == 2) return 6.0/64.0;
if (x == 3) return 54.0/64.0;
if (x == 4) return 9.0/64.0;
if (x == 5) return 57.0/64.0;
if (x == 6) return 5.0/64.0;
if (x == 7) return 53.0/64.0;
} else if (y == 7) {
if (x == 0) return 42.0/64.0;
if (x == 1) return 26.0/64.0;
if (x == 2) return 38.0/64.0;
if (x == 3) return 22.0/64.0;
if (x == 4) return 41.0/64.0;
if (x == 5) return 25.0/64.0;
if (x == 6) return 37.0/64.0;
if (x == 7) return 21.0/64.0;
}
return 0.0;
}
vec3 dither(vec2 uv, vec3 color) {
vec2 scaledCoord = floor(uv * uCardRes / pixelSize);
int x = int(mod(scaledCoord.x, 8.0));
int y = int(mod(scaledCoord.y, 8.0));
float threshold = getBayerValue(x, y) - 0.25;
float step = 1.0 / (colorNum - 1.0);
color += threshold * step;
float bias = 0.2;
color = clamp(color - bias, 0.0, 1.0);
return floor(color * (colorNum - 1.0) + 0.5) / (colorNum - 1.0);
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
vec2 uv = gl_FragCoord.xy / uCardRes.xy;
// Pixelkonsistent: auf Referenzhöhe 180px normieren,
// damit das Dither-Muster in flachen Containern nicht staucht
vec2 centeredUv = (uv - 0.5) * (uCardRes / 180.0);
float f = pattern(centeredUv);
vec2 mouseNDC = ((mousePos / uCardRes - 0.5) * vec2(1.0, -1.0)) * (uCardRes / 180.0);
float dist = length(centeredUv - mouseNDC);
float effect = (1.0 - smoothstep(0.0, mouseRadius, dist)) * mouseActive;
f -= 0.5 * effect;
vec3 col = mix(vec3(0.0), waveColor, f);
col = dither(uv, col);
float m = cardMask(gl_FragCoord.xy);
float a = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0) * m;
gl_FragColor = vec4(col * a, a);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
uniforms: {
time: { value: 0 },
waveSpeed: { value: 0.05 },
waveFrequency: { value: 3 },
waveAmplitude: { value: 0.3 },
waveColor: { value: hexToRgbTuple(props.color) },
mousePos: { value: new Float32Array([0, 0]) },
mouseActive: { value: 0 },
mouseRadius: { value: 1 },
colorNum: { value: 4 },
pixelSize: { value: 2 },
},
color: () => props.color,
colorUniforms: { waveColor: 0 },
frame: ({ t, program, mouse }) => {
const u = program.uniforms
u.time.value = t * 0.001
const res = u.uCardRes.value as Float32Array
const m = u.mousePos.value as Float32Array
m[0] = mouse.x * res[0]
m[1] = (1 - mouse.y) * res[1]
u.mouseActive.value = mouse.active
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
@@ -0,0 +1,246 @@
<script setup lang="ts">
import { Mesh, Program, Renderer, Triangle } from 'ogl'
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const vertex = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
const fragment = `#version 300 es
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
uniform float uSpeed;
uniform float uAmplitude;
uniform float uWaveScale;
uniform float uWaveRatio;
uniform float uSwell;
uniform float uTurbulence;
uniform float uTilt;
uniform float uZoom;
uniform float uHeight;
uniform float uFogDepth;
uniform float uSteps;
uniform float uBrightness;
uniform float uOpacity;
uniform float uGrain;
uniform float uGrainIntensity;
uniform vec2 uMouse;
uniform float uParallax;
uniform bool uEnableMouse;
uniform vec3 uHorizonColor;
uniform vec3 uWaveColor;
uniform vec3 uCrestColor;
uniform float uRadius;
out vec4 fragColor;
const float MAX_DIST = 20000.0;
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float plasma(vec3 r, vec2 freq, vec4 tc) {
float mx = r.x + tc.x;
mx += uSwell * sin((r.y + mx) / 20.0 + tc.y);
float my = r.y - tc.z;
my += uTurbulence * cos(r.x / 23.0 + tc.w);
return r.z - (sin(mx * freq.x) * uAmplitude + sin(my * freq.y) * uAmplitude + uHeight);
}
float raymarch(vec3 pos, vec3 dir, vec2 freq, vec4 tc) {
float dist = 0.0;
for (int i = 0; i < 128; i++) {
if (float(i) >= uSteps) break;
float dscene = plasma(pos + dist * dir, freq, tc);
if (abs(dscene) < 0.1) break;
dist += 0.9 * dscene;
if (!(abs(dist) < MAX_DIST)) return MAX_DIST;
}
return dist;
}
void main() {
float T = iTime * uSpeed;
vec2 freq = vec2(uWaveScale / 7.0, (uWaveScale * uWaveRatio) / 3.0);
vec4 tc = vec4(T / 0.130, T / 0.810, T / 0.200, T / 0.710);
float c, s;
float vfov = (3.14159 / 2.3) / max(uZoom, 0.05);
vec3 cam = vec3(0.0, 0.0, 30.0);
vec2 uv = (gl_FragCoord.xy / iResolution.xy) - 0.5;
uv.x *= iResolution.x / iResolution.y;
uv.y *= -1.0;
vec3 dir = vec3(0.0, 0.0, -1.0);
float ulen = length(uv);
float xrot = vfov * ulen;
c = cos(xrot); s = sin(xrot);
dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
vec2 nuv = ulen > 1e-5 ? uv / ulen : vec2(1.0, 0.0);
c = nuv.x; s = nuv.y;
dir = mat3(c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0) * dir;
c = cos(uTilt); s = sin(uTilt);
dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
if (uEnableMouse) {
float yaw = (uMouse.x - 0.5) * uParallax * 0.4;
float pitch = (uMouse.y - 0.5) * uParallax * 0.4;
c = cos(yaw); s = sin(yaw);
dir = mat3(c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c) * dir;
c = cos(pitch); s = sin(pitch);
dir = mat3(1.0, 0.0, 0.0, 0.0, c, -s, 0.0, s, c) * dir;
}
float dist = raymarch(cam, dir, freq, tc);
vec3 pos = cam + dist * dir;
float t = clamp(uFogDepth / max(dist, 0.001), 0.0, 1.0);
vec3 body = mix(uWaveColor, uCrestColor, clamp(pos.z * 0.08 + 0.5, 0.0, 1.0));
vec3 col = mix(uHorizonColor, body, t);
col *= uBrightness;
col = clamp(col, 0.0, 1.0);
float alpha = clamp(t, 0.0, 1.0) * uOpacity;
if (uGrain > 0.5) {
float g = hash21(gl_FragCoord.xy + mod(iTime, 64.0) * 11.0);
alpha += (g - 0.5) * uGrainIntensity;
}
alpha = clamp(alpha, 0.0, 1.0);
// Rounded-Rect-Maske im Shader (Compositor-sicher, z. B. Waterfox)
vec2 p = gl_FragCoord.xy - iResolution * 0.5;
vec2 hb = iResolution * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
alpha *= 1.0 - smoothstep(-0.75, 0.75, rd);
fragColor = vec4(col * alpha, alpha);
}
`
const containerRef = ref<HTMLDivElement | null>(null)
let teardown: (() => void) | null = null
onMounted(() => {
const container = containerRef.value
if (!container) return
const renderer = new Renderer({
webgl: 2,
alpha: true,
premultipliedAlpha: true,
antialias: false,
dpr: Math.min(window.devicePixelRatio || 1, 1.5),
})
const gl = renderer.gl
gl.clearColor(0, 0, 0, 0)
// WebGL-Canvas bleibt detached; Anzeige über 2D-Blit-Canvas — siehe EffectAurora
const canvas = document.createElement('canvas')
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.style.display = 'block'
canvas.style.borderRadius = 'inherit'
const ctx2d = canvas.getContext('2d')
container.appendChild(canvas)
const geometry = new Triangle(gl)
const program = new Program(gl, {
vertex,
fragment,
uniforms: {
iTime: { value: 0 },
iResolution: { value: new Float32Array([1, 1]) },
uSpeed: { value: 0.4 },
uAmplitude: { value: 2.5 },
uWaveScale: { value: 0.6 },
uWaveRatio: { value: 0.9 },
uSwell: { value: 35 },
uTurbulence: { value: 20 },
uTilt: { value: 1.11 },
uZoom: { value: 1.0 },
uHeight: { value: 5.5 },
uFogDepth: { value: 15 },
uSteps: { value: 40.0 }, // 'low' — kleine Card, muss nicht schwer raymarch-en
uBrightness: { value: 1.0 },
uOpacity: { value: 0.9 },
uGrain: { value: 1.0 },
uGrainIntensity: { value: 0.05 },
uMouse: { value: new Float32Array([0.5, 0.5]) },
uParallax: { value: 0.5 },
uEnableMouse: { value: false },
uHorizonColor: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.55))) },
uWaveColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
uCrestColor: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, 0.55))) },
uRadius: { value: 0 },
},
})
const mesh = new Mesh(gl, { geometry, program })
// Farbwechsel live übernehmen (Settings-Vorschau)
watch(() => props.color, (c) => {
const u = program.uniforms
u.uHorizonColor!.value = new Float32Array(hexToRgbTuple(shadeHex(c, -0.55)))
u.uWaveColor!.value = new Float32Array(hexToRgbTuple(c))
u.uCrestColor!.value = new Float32Array(hexToRgbTuple(shadeHex(c, 0.55)))
})
const setSize = () => {
const rect = container.getBoundingClientRect()
const w = Math.max(1, Math.floor(rect.width))
const h = Math.max(1, Math.floor(rect.height))
renderer.setSize(w, h)
const res = program.uniforms.iResolution.value as Float32Array
res[0] = gl.drawingBufferWidth
res[1] = gl.drawingBufferHeight
const cssRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
program.uniforms.uRadius.value = w > 0 ? cssRadius * (gl.drawingBufferWidth / w) : 0
canvas.width = gl.drawingBufferWidth
canvas.height = gl.drawingBufferHeight
}
const ro = new ResizeObserver(setSize)
ro.observe(container)
setSize()
let raf = 0
const t0 = performance.now()
const loop = (t: number) => {
program.uniforms.iTime.value = (t - t0) * 0.001
renderer.render({ scene: mesh })
ctx2d?.clearRect(0, 0, canvas.width, canvas.height)
ctx2d?.drawImage(gl.canvas, 0, 0, canvas.width, canvas.height)
raf = requestAnimationFrame(loop)
}
raf = requestAnimationFrame(loop)
teardown = () => {
cancelAnimationFrame(raf)
ro.disconnect()
try {
container.removeChild(canvas)
}
catch { /* bereits detached */ }
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
})
onUnmounted(() => {
teardown?.()
teardown = null
})
</script>
<template>
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { hexToRgbTuple, mixHex, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#61dca3',
})
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const fontSize = 14
const charWidth = 9
const charHeight = 18
const lettersAndSymbols = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>?0123456789'
const glitchColors = computed(() => [
shadeHex(props.color, -0.5),
props.color,
shadeHex(props.color, 0.35),
])
interface GlitchLetter {
char: string
color: string
targetColor: string
colorProgress: number
}
let letters: GlitchLetter[] = []
let grid = { columns: 0, rows: 0 }
let ctx: CanvasRenderingContext2D | null = null
let animationId = 0
let lastGlitchTime = 0
let ro: ResizeObserver | null = null
let cornerRadius = 0
const getRandomChar = () => lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]!
const getRandomColor = () => glitchColors.value[Math.floor(Math.random() * glitchColors.value.length)]!
// Farbwechsel live übernehmen (Settings-Vorschau)
watch(() => props.color, () => {
for (const l of letters) {
l.color = getRandomColor()
l.targetColor = getRandomColor()
l.colorProgress = 1
}
draw()
})
function resizeCanvas() {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
const dpr = window.devicePixelRatio || 1
const width = container.offsetWidth
const height = container.offsetHeight
canvas.width = width * dpr
canvas.height = height * dpr
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
cornerRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
grid = {
columns: Math.ceil(width / charWidth),
rows: Math.ceil(height / charHeight),
}
letters = Array.from({ length: grid.columns * grid.rows }, () => ({
char: getRandomChar(),
color: getRandomColor(),
targetColor: getRandomColor(),
colorProgress: 1,
}))
draw()
}
function draw() {
const canvas = canvasRef.value
if (!ctx || !canvas || !letters.length) return
const { width, height } = canvas.getBoundingClientRect()
ctx.clearRect(0, 0, width, height)
// Rounded-Rect-Clip direkt im Canvas — zuverlässig auch wenn der
// Compositor den Layer nicht an CSS-Clips bindet (Waterfox)
ctx.save()
ctx.beginPath()
ctx.roundRect(0, 0, width, height, cornerRadius)
ctx.clip()
ctx.font = `${fontSize}px monospace`
ctx.textBaseline = 'top'
letters.forEach((letter, index) => {
const x = (index % grid.columns) * charWidth
const y = Math.floor(index / grid.columns) * charHeight
ctx!.fillStyle = letter.color
ctx!.fillText(letter.char, x, y)
})
ctx.restore()
}
function animate() {
const now = Date.now()
if (now - lastGlitchTime >= 60) {
const updateCount = Math.max(1, Math.floor(letters.length * 0.05))
for (let i = 0; i < updateCount; i++) {
const index = Math.floor(Math.random() * letters.length)
const letter = letters[index]
if (!letter) continue
letter.char = getRandomChar()
letter.targetColor = getRandomColor()
letter.colorProgress = 0
}
lastGlitchTime = now
}
let needsRedraw = false
for (const letter of letters) {
if (letter.colorProgress < 1) {
letter.colorProgress = Math.min(1, letter.colorProgress + 0.05)
letter.color = mixHex(letter.color, letter.targetColor, letter.colorProgress)
needsRedraw = true
}
}
if (needsRedraw || now - lastGlitchTime < 70) draw()
animationId = requestAnimationFrame(animate)
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
ctx = canvas.getContext('2d')
resizeCanvas()
animate()
ro = new ResizeObserver(resizeCanvas)
if (containerRef.value) ro.observe(containerRef.value)
})
onUnmounted(() => {
cancelAnimationFrame(animationId)
ro?.disconnect()
})
</script>
<template>
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit] opacity-40">
<canvas ref="canvasRef" class="absolute inset-0 size-full rounded-[inherit]" />
</div>
</template>
+170
View File
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `
precision highp float;
uniform float uTime;
uniform float uSpeed;
uniform float uInnerLines;
uniform float uOuterLines;
uniform float uWarpIntensity;
uniform float uRotation;
uniform float uEdgeFadeWidth;
uniform float uColorCycleSpeed;
uniform float uBrightness;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
uniform vec2 uMouse;
uniform float uMouseActive;
uniform float uMouseInfluence;
uniform vec2 uCardRes;
uniform float uRadius;
#define HALF_PI 1.5707963
float hashF(float n) {
return fract(sin(n * 127.1) * 43758.5453123);
}
float smoothNoise(float x) {
float i = floor(x);
float f = fract(x);
float u = f * f * (3.0 - 2.0 * f);
return mix(hashF(i), hashF(i + 1.0), u);
}
float displaceA(float coord, float t) {
float result = sin(coord * 2.123) * 0.2;
result += sin(coord * 3.234 + t * 4.345) * 0.1;
result += sin(coord * 0.589 + t * 0.934) * 0.5;
return result;
}
float displaceB(float coord, float t) {
float result = sin(coord * 1.345) * 0.3;
result += sin(coord * 2.734 + t * 3.345) * 0.2;
result += sin(coord * 0.189 + t * 0.934) * 0.3;
return result;
}
vec2 rotate2D(vec2 p, float angle) {
float c = cos(angle);
float s = sin(angle);
return vec2(p.x * c - p.y * s, p.x * s + p.y * c);
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
// Pixelkonsistent: Koordinaten auf eine Referenzhöhe von 180px normieren,
// damit die Liniendichte in flachen Containern nicht zusammengestaucht wird
vec2 coords = (gl_FragCoord.xy * 2.0 - uCardRes.xy) / 180.0;
coords = rotate2D(coords, uRotation);
float halfT = uTime * uSpeed * 0.5;
float fullT = uTime * uSpeed;
vec2 mPos = rotate2D((uMouse * 2.0 - 1.0) * (uCardRes / 360.0), uRotation);
float mDist = length(coords - mPos);
float mouseWarp = uMouseInfluence * exp(-mDist * mDist * 4.0) * uMouseActive;
float warpAx = coords.x + displaceA(coords.y, halfT) * uWarpIntensity + mouseWarp;
float warpAy = coords.y - displaceA(coords.x * cos(fullT) * 1.235, halfT) * uWarpIntensity;
float warpBx = coords.x + displaceB(coords.y, halfT) * uWarpIntensity + mouseWarp;
float warpBy = coords.y - displaceB(coords.x * sin(fullT) * 1.235, halfT) * uWarpIntensity;
vec2 fieldA = vec2(warpAx, warpAy);
vec2 fieldB = vec2(warpBx, warpBy);
vec2 blended = mix(fieldA, fieldB, mix(fieldA, fieldB, 0.5));
float fadeTop = smoothstep(uEdgeFadeWidth, uEdgeFadeWidth + 0.4, blended.y);
float fadeBottom = smoothstep(-uEdgeFadeWidth, -(uEdgeFadeWidth + 0.4), blended.y);
float vMask = 1.0 - max(fadeTop, fadeBottom);
float tileCount = mix(uOuterLines, uInnerLines, vMask);
float scaledY = blended.y * tileCount;
float nY = smoothNoise(abs(scaledY));
float ridge = pow(
step(abs(nY - blended.x) * 2.0, HALF_PI) * cos(2.0 * (nY - blended.x)),
5.0
);
float waveProfile = max(0.0, (max(fract(scaledY), fract(-scaledY)) - 0.55) / 0.45);
float lines = pow(waveProfile, 2.0) + pow(waveProfile, 4.0);
float pattern = vMask * lines;
float cycleT = fullT * uColorCycleSpeed;
float rChannel = (pattern + lines * ridge) * (cos(blended.y + cycleT * 0.234) * 0.5 + 1.0);
float gChannel = (pattern + vMask * ridge) * (sin(blended.x + cycleT * 1.745) * 0.5 + 1.0);
float bChannel = (pattern + lines * ridge) * (cos(blended.x + cycleT * 0.534) * 0.5 + 1.0);
vec3 col = (rChannel * uColor1 + gChannel * uColor2 + bChannel * uColor3) * uBrightness;
float alpha = clamp(length(col), 0.0, 1.0) * cardMask(gl_FragCoord.xy);
gl_FragColor = vec4(col * alpha, alpha);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
uniforms: {
uTime: { value: 0 },
uSpeed: { value: 0.3 },
uInnerLines: { value: 32 },
uOuterLines: { value: 36 },
uWarpIntensity: { value: 1 },
uRotation: { value: -45 * Math.PI / 180 },
uEdgeFadeWidth: { value: 0 },
uColorCycleSpeed: { value: 1 },
uBrightness: { value: 0.35 },
uColor1: { value: new Float32Array(hexToRgbTuple(props.color)) },
uColor2: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, 0.35))) },
uColor3: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.25))) },
uMouse: { value: new Float32Array([0.5, 0.5]) },
uMouseActive: { value: 0 },
uMouseInfluence: { value: 2.0 },
},
color: () => props.color,
colorUniforms: { uColor1: 0, uColor2: 0.35, uColor3: -0.25 },
frame: ({ t, program, mouse }) => {
const u = program.uniforms
u.uTime.value = t * 0.001
const m = u.uMouse.value as Float32Array
m[0] = mouse.x
m[1] = mouse.y
u.uMouseActive.value = mouse.active
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { Camera, Geometry, Mesh, Program, Renderer } from 'ogl'
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const vertex = /* glsl */ `
attribute vec3 position;
attribute vec4 random;
attribute vec3 color;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
uniform float uTime;
uniform float uSpread;
uniform float uBaseSize;
uniform float uSizeRandomness;
varying vec4 vRandom;
varying vec3 vColor;
void main() {
vRandom = random;
vColor = color;
vec3 pos = position * uSpread;
pos.z *= 10.0;
vec4 mPos = modelMatrix * vec4(pos, 1.0);
float t = uTime;
mPos.x += sin(t * random.z + 6.28 * random.w) * mix(0.1, 1.5, random.x);
mPos.y += sin(t * random.y + 6.28 * random.x) * mix(0.1, 1.5, random.w);
mPos.z += sin(t * random.w + 6.28 * random.y) * mix(0.1, 1.5, random.z);
vec4 mvPos = viewMatrix * mPos;
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
gl_Position = projectionMatrix * mvPos;
}
`
const fragment = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uResolution;
uniform float uRadius;
varying vec4 vRandom;
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord.xy;
float d = length(uv - vec2(0.5));
float circle = smoothstep(0.5, 0.4, d) * 0.8;
// Rounded-Rect-Maske im Shader (Compositor-sicher, z. B. Waterfox)
vec2 p = gl_FragCoord.xy - uResolution * 0.5;
vec2 hb = uResolution * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
circle *= 1.0 - smoothstep(-0.75, 0.75, rd);
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle);
}
`
let cleanup: (() => void) | null = null
onMounted(() => {
const container = containerRef.value
if (!container) return
const renderer = new Renderer({ dpr: 1, depth: false, alpha: true })
const gl = renderer.gl
// WebGL-Canvas bleibt detached; Anzeige über 2D-Blit-Canvas — siehe EffectAurora
const outCanvas = document.createElement('canvas')
outCanvas.className = 'absolute inset-0 size-full rounded-[inherit]'
const outCtx = outCanvas.getContext('2d')
container.appendChild(outCanvas)
gl.clearColor(0, 0, 0, 0)
const camera = new Camera(gl, { fov: 15 })
camera.position.set(0, 0, 20)
const resize = () => {
renderer.setSize(container.clientWidth, container.clientHeight)
camera.perspective({ aspect: gl.canvas.width / gl.canvas.height })
const res = program.uniforms.uResolution.value as Float32Array
res[0] = gl.drawingBufferWidth
res[1] = gl.drawingBufferHeight
const cssRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
program.uniforms.uRadius.value = container.clientWidth > 0
? cssRadius * (gl.drawingBufferWidth / container.clientWidth)
: 0
outCanvas.width = gl.drawingBufferWidth
outCanvas.height = gl.drawingBufferHeight
}
const ro = new ResizeObserver(resize)
const count = 120
const positions = new Float32Array(count * 3)
const randoms = new Float32Array(count * 4)
const colors = new Float32Array(count * 3)
const palette = [props.color, shadeHex(props.color, 0.4), '#ffffff']
for (let i = 0; i < count; i++) {
let x = 0
let y = 0
let z = 0
let len = 0
do {
x = Math.random() * 2 - 1
y = Math.random() * 2 - 1
z = Math.random() * 2 - 1
len = x * x + y * y + z * z
} while (len > 1 || len === 0)
const r = Math.cbrt(Math.random())
positions.set([x * r, y * r, z * r], i * 3)
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4)
colors.set(hexToRgbTuple(palette[Math.floor(Math.random() * palette.length)]!), i * 3)
}
const geometry = new Geometry(gl, {
position: { size: 3, data: positions },
random: { size: 4, data: randoms },
color: { size: 3, data: colors },
})
const program = new Program(gl, {
vertex,
fragment,
uniforms: {
uTime: { value: 0 },
uSpread: { value: 10 },
uBaseSize: { value: 100 },
uSizeRandomness: { value: 1 },
uResolution: { value: new Float32Array([1, 1]) },
uRadius: { value: 0 },
},
transparent: true,
depthTest: false,
})
const particles = new Mesh(gl, { mode: gl.POINTS, geometry, program })
// Farbwechsel live übernehmen (Settings-Vorschau)
watch(() => props.color, (c) => {
const p = [c, shadeHex(c, 0.4), '#ffffff']
for (let i = 0; i < count; i++) {
colors.set(hexToRgbTuple(p[Math.floor(Math.random() * p.length)]!), i * 3)
}
geometry.attributes.color!.needsUpdate = true
})
ro.observe(container)
resize()
let animationFrameId = 0
let lastTime = performance.now()
let elapsed = 0
const update = (t: number) => {
animationFrameId = requestAnimationFrame(update)
const delta = t - lastTime
lastTime = t
elapsed += delta * 0.1
program.uniforms.uTime.value = elapsed * 0.001
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15
particles.rotation.z += 0.001
renderer.render({ scene: particles, camera })
outCtx?.clearRect(0, 0, outCanvas.width, outCanvas.height)
outCtx?.drawImage(gl.canvas, 0, 0, outCanvas.width, outCanvas.height)
}
animationFrameId = requestAnimationFrame(update)
cleanup = () => {
ro.disconnect()
cancelAnimationFrame(animationFrameId)
if (container.contains(outCanvas)) container.removeChild(outCanvas)
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
})
onBeforeUnmount(() => {
cleanup?.()
cleanup = null
})
</script>
<template>
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { hexToRgbTuple } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `#version 300 es
precision highp float;
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `#version 300 es
precision highp float;
uniform float iTime;
uniform vec3 uCustomColor;
uniform float uSpeed;
uniform float uScale;
uniform float uOpacity;
uniform vec2 uMouse;
uniform float uMouseActive;
uniform float uIterations;
uniform float uStepScale;
uniform vec2 uCardRes;
uniform float uRadius;
out vec4 fragColor;
void mainImage(out vec4 o, vec2 C) {
// Pixelkonsistent: Koordinatenrahmen auf Referenzhöhe 180px normieren —
// r.y ist die Brennweite und würde in flachen Containern die
// Perspektive extrem verzerren
float pxScale = 180.0 / uCardRes.y;
vec2 r = uCardRes * pxScale;
C *= pxScale;
vec2 center = r * 0.5;
C = (C - center) / uScale + center;
vec2 mouseOffset = (uMouse * pxScale - center) * 0.0002;
C += mouseOffset * length(C - center) * uMouseActive;
float i, d, z, T = iTime * uSpeed;
vec3 O, p, S;
for (vec2 Q; ++i < 60.; O += o.w/d*o.xyz) {
p = z*normalize(vec3(C-.5*r,r.y));
p.z -= 4.;
S = p;
d = p.y-T;
p.x += .4*(1.+p.y)*sin(d + p.x*0.1)*cos(.34*d + p.x*0.05);
Q = p.xz *= mat2(cos(p.y+vec4(0,11,33,0)-T));
z+= d = (abs(sqrt(length(Q*Q)) - .25*(5.+S.y))/3.+8e-4)*uStepScale;
o = 1.+sin(S.y+p.z*.5+S.z-length(S-p)+vec4(2,1,0,8));
if (i >= uIterations) break;
}
o.xyz = tanh(O/1e4);
}
bool finite1(float x){ return !(isnan(x) || isinf(x)); }
vec3 sanitize(vec3 c){
return vec3(
finite1(c.r) ? c.r : 0.0,
finite1(c.g) ? c.g : 0.0,
finite1(c.b) ? c.b : 0.0
);
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
vec4 o = vec4(0.0);
mainImage(o, gl_FragCoord.xy);
vec3 rgb = sanitize(o.rgb);
float intensity = (rgb.r + rgb.g + rgb.b) / 3.0;
vec3 finalColor = intensity * uCustomColor;
float a = length(rgb) * uOpacity * cardMask(gl_FragCoord.xy);
fragColor = vec4(finalColor * a, a);
}`
const ctnDom = ref<HTMLDivElement | null>(null)
let elapsed = 0
let lastT = 0
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
renderScale: 0.55,
uniforms: {
iTime: { value: 0 },
uCustomColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
uSpeed: { value: 0.4 },
uScale: { value: 1 },
uOpacity: { value: 1 },
uMouse: { value: new Float32Array([0, 0]) },
uMouseActive: { value: 0 },
uIterations: { value: 48 },
uStepScale: { value: 60 / 48 },
},
color: () => props.color,
colorUniforms: { uCustomColor: 0 },
frame: ({ t, program, mouse }) => {
const u = program.uniforms
// Zeit akkumulieren, damit Pausen (Tab-Wechsel) nicht springen
const delta = Math.min((t - lastT) * 0.001, 0.1)
lastT = t
elapsed += delta
u.iTime.value = elapsed
const res = u.uCardRes.value as Float32Array
const m = u.uMouse.value as Float32Array
m[0] = mouse.x * res[0]
m[1] = (1 - mouse.y) * res[1]
u.uMouseActive.value = mouse.active
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `#version 300 es
precision highp float;
uniform float iTime;
uniform float uSpeed;
uniform float uSweepSpeed;
uniform float uSweepWidth;
uniform float uSweepFalloff;
uniform float uScale;
uniform float uFrequency;
uniform float uRipple;
uniform float uBandDensity;
uniform float uLineSharpness;
uniform float uGlow;
uniform float uColorSpread;
uniform float uBrightness;
uniform float uContrast;
uniform float uSoftness;
uniform float uVignette;
uniform float uOpacity;
uniform float uScanline;
uniform float uGrain;
uniform float uGrainIntensity;
uniform vec2 uMouse;
uniform float uMouseRadius;
uniform float uMouseStrength;
uniform float uMouseActive;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform vec3 uColor3;
uniform vec2 uCardRes;
uniform float uRadius;
out vec4 fragColor;
const float TAU = 6.2831853;
float signalField(vec2 p, float t) {
float w = sin(p.x * 1.3 + t * 0.7);
w += sin(p.y * 1.7 - t * 0.52) * 0.8;
w += sin((p.x + p.y) * 0.9 + t * 0.91) * 0.6;
w += sin((p.x - p.y) * 1.53 - t * 0.63) * 0.42;
return w * 0.35;
}
vec3 palette(float f) {
f = clamp(f, 0.0, 1.0);
f = pow(f, uContrast);
vec3 c = mix(uColor1, uColor2, smoothstep(0.08, 0.6, f));
return mix(c, uColor3, smoothstep(0.68, 1.0, f));
}
float scanBand(float x, float aa, float sharp) {
float v = mix(0.5, 0.5 + 0.5 * cos(x * TAU), aa);
return pow(v, sharp);
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
// Pixelkonsistent: auf Referenzhöhe 180px normieren,
// damit Bänder/Wellen in flachen Containern nicht stauchen
vec2 uv0 = (gl_FragCoord.xy * 2.0 - uCardRes.xy) / 180.0;
vec2 p = uv0 / max(uScale, 0.001);
float t = iTime * uSpeed;
vec2 mUv = (uMouse * 2.0 - 1.0) * (uCardRes / 360.0);
vec2 md = uv0 - mUv;
float r = max(uMouseRadius, 0.001);
float mouseBoost = exp(-dot(md, md) / (r * r)) * uMouseStrength * uMouseActive;
float axis = p.y;
float sig = signalField(p * uFrequency, t);
float coord = axis + sig * uRipple;
float phase = coord / max(uSweepWidth, 0.05) - t * uSweepSpeed;
float sweep = pow(0.5 + 0.5 * cos(phase * TAU), max(uSweepFalloff, 0.1));
float lc = coord * uBandDensity;
float aa = 1.0 / (1.0 + uSoftness * fwidth(lc) * 3.0);
aa = clamp(aa * (1.0 + mouseBoost * 0.6), 0.0, 1.0);
float bodyBase = clamp(0.5 + 0.5 * sig, 0.0, 1.0);
float body = bodyBase * bodyBase * uGlow * sweep;
float sharp = max(uLineSharpness, 0.1);
float split = uColorSpread * 0.16;
float fr = clamp(scanBand(lc + split, aa, sharp) * sweep + body, 0.0, 1.0);
float fg = clamp(scanBand(lc, aa, sharp) * sweep + body, 0.0, 1.0);
float fb = clamp(scanBand(lc - split, aa, sharp) * sweep + body, 0.0, 1.0);
vec3 col = vec3(palette(fr).r, palette(fg).g, palette(fb).b);
float inten = (fr + fg + fb) * 0.3333333 * uBrightness;
inten *= 1.0 + mouseBoost * 0.9;
inten *= 1.0 - 0.18 * (0.5 + 0.5 * cos(gl_FragCoord.y * 1.7));
if (inten > 0.01) {
float g = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233)) + iTime) * 43758.5453);
inten += (g - 0.5) * uGrainIntensity;
}
inten *= clamp(1.0 - uVignette * smoothstep(0.55, 1.65, length(uv0)), 0.0, 1.0);
inten = clamp(inten, 0.0, 1.0);
float a = clamp(inten * uOpacity, 0.0, 1.0) * cardMask(gl_FragCoord.xy);
fragColor = vec4(clamp(col, 0.0, 1.0) * a, a);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
uniforms: {
iTime: { value: 0 },
uSpeed: { value: 0.5 },
uSweepSpeed: { value: 0.25 },
uSweepWidth: { value: 1.6 },
uSweepFalloff: { value: 6 },
uScale: { value: 1.5 },
uFrequency: { value: 2 },
uRipple: { value: 0.22 },
uBandDensity: { value: 11 },
uLineSharpness: { value: 5.5 },
uGlow: { value: 0.22 },
uColorSpread: { value: 0.7 },
uBrightness: { value: 1.0 },
uContrast: { value: 1.15 },
uSoftness: { value: 1.4 },
uVignette: { value: 0.45 },
uOpacity: { value: 1.0 },
uScanline: { value: 1.0 },
uGrain: { value: 1.0 },
uGrainIntensity: { value: 0.05 },
uMouse: { value: new Float32Array([0.5, 0.5]) },
uMouseRadius: { value: 0.5 },
uMouseStrength: { value: 0.5 },
uMouseActive: { value: 0 },
uColor1: { value: new Float32Array(hexToRgbTuple(shadeHex(props.color, -0.55))) },
uColor2: { value: new Float32Array(hexToRgbTuple(props.color)) },
uColor3: { value: new Float32Array([1, 1, 1]) },
},
color: () => props.color,
colorUniforms: { uColor1: -0.55, uColor2: 0 },
frame: ({ t, program, mouse }) => {
const u = program.uniforms
u.iTime.value = t * 0.001
const m = u.uMouse.value as Float32Array
m[0] = mouse.x
m[1] = mouse.y
u.uMouseActive.value = mouse.active
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+304
View File
@@ -0,0 +1,304 @@
<script setup lang="ts">
import { hexToRgbTuple, shadeHex } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
// Scroll-Grid + Hover-Fill/Trail auf 2D-Canvas
const props = withDefaults(defineProps<{
color?: string
shape?: 'square' | 'hexagon' | 'circle' | 'triangle'
}>(), {
color: '#6366f1',
shape: 'square',
})
const canvasRef = ref<HTMLCanvasElement | null>(null)
let cleanup: (() => void) | null = null
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const squareSize = 30
const speed = 0.6
const hoverTrailAmount = 10
let borderColor = ''
let hoverFill = ''
let edgeRgba = ''
const updateColors = () => {
const [r, g, b] = hexToRgbTuple(props.color)
borderColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.3)`
hoverFill = props.color
const edgeColor = hexToRgbTuple(shadeHex(props.color, -0.85))
edgeRgba = `rgba(${Math.round(edgeColor[0] * 255)}, ${Math.round(edgeColor[1] * 255)}, ${Math.round(edgeColor[2] * 255)}, 0.9)`
}
updateColors()
// Farbwechsel live übernehmen (Settings-Vorschau)
watch(() => props.color, updateColors)
const isHex = props.shape === 'hexagon'
const isTri = props.shape === 'triangle'
const hexHoriz = squareSize * 1.5
const hexVert = squareSize * Math.sqrt(3)
const offset = { x: 0, y: 0 }
let hovered: { x: number, y: number } | null = null
const trail: { x: number, y: number }[] = []
const opacities = new Map<string, number>()
const resizeCanvas = () => {
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
}
const drawHex = (cx: number, cy: number, size: number) => {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const angle = (Math.PI / 3) * i
const vx = cx + size * Math.cos(angle)
const vy = cy + size * Math.sin(angle)
if (i === 0) ctx.moveTo(vx, vy)
else ctx.lineTo(vx, vy)
}
ctx.closePath()
}
const drawCircle = (cx: number, cy: number, size: number) => {
ctx.beginPath()
ctx.arc(cx, cy, size / 2, 0, Math.PI * 2)
}
const drawTriangle = (cx: number, cy: number, size: number, flip: boolean) => {
ctx.beginPath()
if (flip) {
ctx.moveTo(cx, cy + size / 2)
ctx.lineTo(cx + size / 2, cy - size / 2)
ctx.lineTo(cx - size / 2, cy - size / 2)
}
else {
ctx.moveTo(cx, cy - size / 2)
ctx.lineTo(cx + size / 2, cy + size / 2)
ctx.lineTo(cx - size / 2, cy + size / 2)
}
ctx.closePath()
}
const drawGrid = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const cell = (key: string, draw: () => void) => {
const alpha = opacities.get(key)
ctx.strokeStyle = borderColor
ctx.globalAlpha = 0.55
if (alpha) {
ctx.globalAlpha = alpha
ctx.fillStyle = hoverFill
draw()
ctx.fill()
ctx.globalAlpha = 1
draw()
ctx.stroke()
}
else {
draw()
ctx.stroke()
}
ctx.globalAlpha = 1
}
if (isHex) {
const colShift = Math.floor(offset.x / hexHoriz)
const offsetX = ((offset.x % hexHoriz) + hexHoriz) % hexHoriz
const offsetY = ((offset.y % hexVert) + hexVert) % hexVert
const cols = Math.ceil(canvas.width / hexHoriz) + 3
const rows = Math.ceil(canvas.height / hexVert) + 3
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * hexHoriz + offsetX
const cy = row * hexVert + ((col + colShift) % 2 !== 0 ? hexVert / 2 : 0) + offsetY
cell(`${col},${row}`, () => drawHex(cx, cy, squareSize))
}
}
}
else if (isTri) {
const halfW = squareSize / 2
const colShift = Math.floor(offset.x / halfW)
const rowShift = Math.floor(offset.y / squareSize)
const offsetX = ((offset.x % halfW) + halfW) % halfW
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const cols = Math.ceil(canvas.width / halfW) + 4
const rows = Math.ceil(canvas.height / squareSize) + 4
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * halfW + offsetX
const cy = row * squareSize + squareSize / 2 + offsetY
const flip = (((col + colShift + row + rowShift) % 2) + 2) % 2 !== 0
cell(`${col},${row}`, () => drawTriangle(cx, cy, squareSize, flip))
}
}
}
else if (props.shape === 'circle') {
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const cols = Math.ceil(canvas.width / squareSize) + 3
const rows = Math.ceil(canvas.height / squareSize) + 3
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const cx = col * squareSize + squareSize / 2 + offsetX
const cy = row * squareSize + squareSize / 2 + offsetY
cell(`${col},${row}`, () => drawCircle(cx, cy, squareSize))
}
}
}
else {
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const cols = Math.ceil(canvas.width / squareSize) + 3
const rows = Math.ceil(canvas.height / squareSize) + 3
for (let col = -2; col < cols; col++) {
for (let row = -2; row < rows; row++) {
const sx = col * squareSize + offsetX
const sy = row * squareSize + offsetY
cell(`${col},${row}`, () => {
ctx.beginPath()
ctx.rect(sx, sy, squareSize, squareSize)
})
}
}
}
// dunkle Rand-Vignette, aus der Effektfarbe abgeleitet
const gradient = ctx.createRadialGradient(
canvas.width / 2, canvas.height / 2, 0,
canvas.width / 2, canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2,
)
gradient.addColorStop(0, 'rgba(0, 0, 0, 0)')
gradient.addColorStop(1, edgeRgba)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
const updateCellOpacities = () => {
const targets = new Map<string, number>()
if (hovered) targets.set(`${hovered.x},${hovered.y}`, 1)
for (let i = 0; i < trail.length; i++) {
const t = trail[i]
const key = `${t.x},${t.y}`
if (!targets.has(key)) targets.set(key, (trail.length - i) / (trail.length + 1))
}
for (const [key] of targets) {
if (!opacities.has(key)) opacities.set(key, 0)
}
for (const [key, opacity] of opacities) {
const next = opacity + ((targets.get(key) || 0) - opacity) * 0.15
if (next < 0.005) opacities.delete(key)
else opacities.set(key, next)
}
}
let raf = 0
let visible = true
const updateAnimation = () => {
if (!visible || document.hidden) {
raf = 0
return
}
raf = requestAnimationFrame(updateAnimation)
const wrapX = isHex ? hexHoriz * 2 : squareSize
const wrapY = isHex ? hexVert : isTri ? squareSize * 2 : squareSize
// diagonal
offset.x = (offset.x - speed + wrapX) % wrapX
offset.y = (offset.y - speed + wrapY) % wrapY
updateCellOpacities()
drawGrid()
}
const updateHovered = (col: number, row: number) => {
if (!hovered || hovered.x !== col || hovered.y !== row) {
if (hovered) {
trail.unshift({ ...hovered })
if (trail.length > hoverTrailAmount) trail.length = hoverTrailAmount
}
hovered = { x: col, y: row }
}
}
const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect()
if (event.clientX < rect.left || event.clientX > rect.right
|| event.clientY < rect.top || event.clientY > rect.bottom) {
if (hovered) {
trail.unshift({ ...hovered })
if (trail.length > hoverTrailAmount) trail.length = hoverTrailAmount
hovered = null
}
return
}
const mouseX = event.clientX - rect.left
const mouseY = event.clientY - rect.top
if (isHex) {
const colShift = Math.floor(offset.x / hexHoriz)
const offsetX = ((offset.x % hexHoriz) + hexHoriz) % hexHoriz
const offsetY = ((offset.y % hexVert) + hexVert) % hexVert
const adjustedX = mouseX - offsetX
const adjustedY = mouseY - offsetY
const col = Math.round(adjustedX / hexHoriz)
const rowOffset = (col + colShift) % 2 !== 0 ? hexVert / 2 : 0
const row = Math.round((adjustedY - rowOffset) / hexVert)
updateHovered(col, row)
}
else if (isTri) {
const halfW = squareSize / 2
const offsetX = ((offset.x % halfW) + halfW) % halfW
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const col = Math.round((mouseX - offsetX) / halfW)
const row = Math.floor((mouseY - offsetY) / squareSize)
updateHovered(col, row)
}
else {
const offsetX = ((offset.x % squareSize) + squareSize) % squareSize
const offsetY = ((offset.y % squareSize) + squareSize) % squareSize
const rounded = props.shape === 'circle' ? Math.round : Math.floor
const col = rounded((mouseX - offsetX) / squareSize)
const row = rounded((mouseY - offsetY) / squareSize)
updateHovered(col, row)
}
}
const io = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true
if (visible && !raf) raf = requestAnimationFrame(updateAnimation)
})
io.observe(canvas)
const ro = new ResizeObserver(resizeCanvas)
ro.observe(canvas)
window.addEventListener('pointermove', onPointerMove, { passive: true })
resizeCanvas()
raf = requestAnimationFrame(updateAnimation)
cleanup = () => {
cancelAnimationFrame(raf)
io.disconnect()
ro.disconnect()
window.removeEventListener('pointermove', onPointerMove)
}
})
onBeforeUnmount(() => {
cleanup?.()
cleanup = null
})
</script>
<template>
<canvas ref="canvasRef" class="absolute inset-0 block size-full rounded-[inherit] border-none" />
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { hexToRgbTuple } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), Plane+Camera auf Fullscreen-Triangle
// vereinfacht, nur color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform vec3 uColor;
uniform float uSpeed;
uniform float uScale;
uniform float uRotation;
uniform float uNoiseIntensity;
uniform vec2 uCardRes;
uniform float uRadius;
const float e = 2.71828182845904523536;
float noise(vec2 texCoord) {
float G = e;
vec2 r = (G * sin(G * texCoord));
return fract(r.x * r.y * (1.0 + texCoord.x));
}
vec2 rotateUvs(vec2 uv, float angle) {
float c = cos(angle);
float s = sin(angle);
mat2 rot = mat2(c, -s, s, c);
return rot * uv;
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
float rnd = noise(gl_FragCoord.xy);
// Pixelkonsistent: Muster-Koordinaten auf eine Referenzgröße von 180px
// normieren, damit flache Container den Effekt nicht vertikal stauchen
vec2 suv = vUv * (uCardRes / 180.0);
vec2 uv = rotateUvs(suv * uScale, uRotation);
vec2 tex = uv * uScale;
float tOffset = uSpeed * uTime;
tex.y += 0.03 * sin(8.0 * tex.x - tOffset);
float pattern = 0.6 +
0.4 * sin(5.0 * (tex.x + tex.y +
cos(3.0 * tex.x + 5.0 * tex.y) +
0.02 * tOffset) +
sin(20.0 * (tex.x + tex.y - 0.1 * tOffset)));
vec4 col = vec4(uColor, 1.0) * vec4(pattern) - rnd / 15.0 * uNoiseIntensity;
float m = cardMask(gl_FragCoord.xy);
float a = clamp(pattern * 0.45, 0.0, 1.0) * m;
gl_FragColor = vec4(col.rgb * a, a);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
let elapsed = 0
let lastT = 0
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uColor: { value: new Float32Array(hexToRgbTuple(props.color)) },
uSpeed: { value: 5 },
uScale: { value: 1 },
uNoiseIntensity: { value: 1.5 },
uRotation: { value: 0 },
},
color: () => props.color,
colorUniforms: { uColor: 0 },
frame: ({ t, program }) => {
const delta = Math.min((t - lastT) * 0.001, 0.1)
lastT = t
elapsed += 0.1 * delta
program.uniforms.uTime.value = elapsed
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+185
View File
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { hexToRgbTuple } from '@/lib/color'
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
const VERT = `
attribute vec2 uv;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`
const FRAG = `
precision highp float;
uniform float uTime;
uniform float uSpeed;
uniform float uScale;
uniform float uBrightness;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform float uNoiseFreq;
uniform float uNoiseAmp;
uniform float uBandHeight;
uniform float uBandSpread;
uniform float uOctaveDecay;
uniform float uLayerOffset;
uniform float uColorSpeed;
uniform vec2 uMouse;
uniform float uMouseInfluence;
uniform vec2 uCardRes;
uniform float uRadius;
#define TAU 6.28318
vec3 gradientHash(vec3 p) {
p = vec3(
dot(p, vec3(127.1, 311.7, 234.6)),
dot(p, vec3(269.5, 183.3, 198.3)),
dot(p, vec3(169.5, 283.3, 156.9))
);
vec3 h = fract(sin(p) * 43758.5453123);
float phi = acos(2.0 * h.x - 1.0);
float theta = TAU * h.y;
return vec3(cos(theta) * sin(phi), sin(theta) * cos(phi), cos(phi));
}
float quinticSmooth(float t) {
float t2 = t * t;
float t3 = t * t2;
return 6.0 * t3 * t2 - 15.0 * t2 * t2 + 10.0 * t3;
}
vec3 cosineGradient(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(TAU * (c * t + d));
}
float perlin3D(float amplitude, float frequency, float px, float py, float pz) {
float x = px * frequency;
float y = py * frequency;
float fx = floor(x); float fy = floor(y); float fz = floor(pz);
float cx = ceil(x); float cy = ceil(y); float cz = ceil(pz);
vec3 g000 = gradientHash(vec3(fx, fy, fz));
vec3 g100 = gradientHash(vec3(cx, fy, fz));
vec3 g010 = gradientHash(vec3(fx, cy, fz));
vec3 g110 = gradientHash(vec3(cx, cy, fz));
vec3 g001 = gradientHash(vec3(fx, fy, cz));
vec3 g101 = gradientHash(vec3(cx, fy, cz));
vec3 g011 = gradientHash(vec3(fx, cy, cz));
vec3 g111 = gradientHash(vec3(cx, cy, cz));
float d000 = dot(g000, vec3(x - fx, y - fy, pz - fz));
float d100 = dot(g100, vec3(x - cx, y - fy, pz - fz));
float d010 = dot(g010, vec3(x - fx, y - cy, pz - fz));
float d110 = dot(g110, vec3(x - cx, y - cy, pz - fz));
float d001 = dot(g001, vec3(x - fx, y - fy, pz - cz));
float d101 = dot(g101, vec3(x - cx, y - fy, pz - cz));
float d011 = dot(g011, vec3(x - fx, y - cy, pz - cz));
float d111 = dot(g111, vec3(x - cx, y - cy, pz - cz));
float sx = quinticSmooth(x - fx);
float sy = quinticSmooth(y - fy);
float sz = quinticSmooth(pz - fz);
float lx00 = mix(d000, d100, sx);
float lx10 = mix(d010, d110, sx);
float lx01 = mix(d001, d101, sx);
float lx11 = mix(d011, d111, sx);
float ly0 = mix(lx00, lx10, sy);
float ly1 = mix(lx01, lx11, sy);
return amplitude * mix(ly0, ly1, sz);
}
float auroraGlow(float t, vec2 shift) {
// Pixelkonsistent: Referenzhöhe 180px — die Noise-/Band-Struktur behält
// in flachen Containern ihre reale Größe statt zu stauchen
vec2 uv = gl_FragCoord.xy / 180.0;
uv += shift;
float noiseVal = 0.0;
float freq = uNoiseFreq;
float amp = uNoiseAmp;
vec2 samplePos = uv * uScale;
for (float i = 0.0; i < 3.0; i += 1.0) {
noiseVal += perlin3D(amp, freq, samplePos.x, samplePos.y, t);
amp *= uOctaveDecay;
freq *= 2.0;
}
float yBand = uv.y * 10.0 - uBandHeight * 10.0;
return 0.3 * max(exp(uBandSpread * (1.0 - 1.1 * abs(noiseVal + yBand))), 0.0);
}
float cardMask(vec2 fc) {
vec2 p = fc - uCardRes * 0.5;
vec2 hb = uCardRes * 0.5 - vec2(uRadius);
float rd = length(max(abs(p) - hb, 0.0)) - uRadius;
return 1.0 - smoothstep(-0.75, 0.75, rd);
}
void main() {
vec2 uv = gl_FragCoord.xy / uCardRes.xy;
float t = uSpeed * 0.4 * uTime;
vec2 shift = (uMouse - 0.5) * uMouseInfluence;
vec3 col = vec3(0.0);
col += 0.99 * auroraGlow(t, shift) * cosineGradient(uv.x + uTime * uSpeed * 0.2 * uColorSpeed, vec3(0.5), vec3(0.5), vec3(1.0), vec3(0.3, 0.20, 0.20)) * uColor1;
col += 0.99 * auroraGlow(t + uLayerOffset, shift) * cosineGradient(uv.x + uTime * uSpeed * 0.1 * uColorSpeed, vec3(0.5), vec3(0.5), vec3(2.0, 1.0, 0.0), vec3(0.5, 0.20, 0.25)) * uColor2;
col *= uBrightness;
float alpha = clamp(length(col), 0.0, 1.0) * cardMask(gl_FragCoord.xy);
gl_FragColor = vec4(col * alpha, alpha);
}
`
const ctnDom = ref<HTMLDivElement | null>(null)
useCardShader(ctnDom, {
vertex: VERT,
fragment: FRAG,
mouse: true,
uniforms: {
uTime: { value: 0 },
uSpeed: { value: 0.6 },
uScale: { value: 1.5 },
uBrightness: { value: 1.0 },
uColor1: { value: new Float32Array([0.97, 0.97, 0.97]) },
uColor2: { value: new Float32Array(hexToRgbTuple(props.color)) },
uNoiseFreq: { value: 2.5 },
uNoiseAmp: { value: 1.0 },
uBandHeight: { value: 0.5 },
uBandSpread: { value: 1.0 },
uOctaveDecay: { value: 0.1 },
uLayerOffset: { value: 0 },
uColorSpeed: { value: 1.0 },
uMouse: { value: new Float32Array([0.5, 0.5]) },
uMouseInfluence: { value: 0.25 },
},
color: () => props.color,
colorUniforms: { uColor2: 0 },
frame: ({ t, program, mouse }) => {
const u = program.uniforms
u.uTime.value = t * 0.001
const m = u.uMouse.value as Float32Array
m[0] = mouse.x
m[1] = mouse.y
},
})
</script>
<template>
<div ref="ctnDom" class="relative size-full overflow-hidden rounded-[inherit]" />
</template>
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
// Portiert aus Vue Bits (DavidHDev/vue-bits), vereinfacht auf color-Prop,
// ohne Cursor-Interaktion (Hover-Card ist zu klein dafür)
const props = withDefaults(defineProps<{ color?: string }>(), {
color: '#6366f1',
})
class Grad {
constructor(
public x: number,
public y: number,
public z: number,
) {}
dot2(x: number, y: number): number {
return this.x * x + this.y * y
}
}
class Noise {
grad3 = [
new Grad(1, 1, 0), new Grad(-1, 1, 0), new Grad(1, -1, 0), new Grad(-1, -1, 0),
new Grad(1, 0, 1), new Grad(-1, 0, 1), new Grad(1, 0, -1), new Grad(-1, 0, -1),
new Grad(0, 1, 1), new Grad(0, -1, 1), new Grad(0, 1, -1), new Grad(0, -1, -1),
]
p = [
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
195, 78, 66, 215, 61, 156, 180,
]
perm = new Array(512)
gradP = new Array(512)
constructor(seed = 0) {
this.seed(seed)
}
seed(seed: number) {
if (seed > 0 && seed < 1) seed *= 65536
seed = Math.floor(seed)
if (seed < 256) seed |= seed << 8
for (let i = 0; i < 256; i++) {
const v = i & 1 ? this.p[i]! ^ (seed & 255) : this.p[i]! ^ ((seed >> 8) & 255)
this.perm[i] = this.perm[i + 256] = v
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12]
}
}
fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10)
}
lerp(a: number, b: number, t: number): number {
return (1 - t) * a + t * b
}
perlin2(x: number, y: number): number {
let X = Math.floor(x)
let Y = Math.floor(y)
x -= X
y -= Y
X &= 255
Y &= 255
const n00 = this.gradP[X + this.perm[Y]!]!.dot2(x, y)
const n01 = this.gradP[X + this.perm[Y + 1]!]!.dot2(x, y - 1)
const n10 = this.gradP[X + 1 + this.perm[Y]!]!.dot2(x - 1, y)
const n11 = this.gradP[X + 1 + this.perm[Y + 1]!]!.dot2(x - 1, y - 1)
const u = this.fade(x)
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y))
}
}
interface Point {
x: number
y: number
wave: { x: number, y: number }
}
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
let ctx: CanvasRenderingContext2D | null = null
let bounding = { width: 0, height: 0 }
let cornerRadius = 0
let noise: Noise | null = null
let lines: Point[][] = []
let frameId: number | null = null
const xGap = 10
const yGap = 26
function setSize() {
const container = containerRef.value
const canvas = canvasRef.value
if (!container || !canvas) return
const rect = container.getBoundingClientRect()
bounding = { width: rect.width, height: rect.height }
const dpr = window.devicePixelRatio || 1
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
cornerRadius = parseFloat(getComputedStyle(container).borderTopLeftRadius) || 0
}
function setLines() {
const { width, height } = bounding
lines = []
const oWidth = width + 200
const oHeight = height + 30
const totalLines = Math.ceil(oWidth / xGap)
const totalPoints = Math.ceil(oHeight / yGap)
const xStart = (width - xGap * totalLines) / 2
const yStart = (height - yGap * totalPoints) / 2
for (let i = 0; i <= totalLines; i++) {
const pts: Point[] = []
for (let j = 0; j <= totalPoints; j++) {
pts.push({ x: xStart + xGap * i, y: yStart + yGap * j, wave: { x: 0, y: 0 } })
}
lines.push(pts)
}
}
function tick(t: number) {
if (!ctx || !noise) return
const { width, height } = bounding
for (const pts of lines) {
for (const p of pts) {
const move = noise.perlin2((p.x + t * 0.0125) * 0.002, (p.y + t * 0.005) * 0.0015) * 12
p.wave.x = Math.cos(move) * 32
p.wave.y = Math.sin(move) * 16
}
}
ctx.clearRect(0, 0, width, height)
// Rounded-Rect-Clip direkt im Canvas — zuverlässig auch wenn der
// Compositor den Layer nicht an CSS-Clips bindet (Waterfox)
ctx.save()
ctx.beginPath()
ctx.roundRect(0, 0, width, height, cornerRadius)
ctx.clip()
ctx.beginPath()
ctx.strokeStyle = props.color
for (const points of lines) {
let p1 = points[0]!
ctx.moveTo(p1.x + p1.wave.x, p1.y + p1.wave.y)
for (const p of points) {
ctx.lineTo(Math.round((p.x + p.wave.x) * 10) / 10, Math.round((p.y + p.wave.y) * 10) / 10)
}
p1 = points[points.length - 1]!
ctx.moveTo(p1.x + p1.wave.x, p1.y + p1.wave.y)
}
ctx.stroke()
ctx.restore()
frameId = requestAnimationFrame(tick)
}
let ro: ResizeObserver | null = null
onMounted(() => {
const canvas = canvasRef.value
const container = containerRef.value
if (!canvas || !container) return
ctx = canvas.getContext('2d')
noise = new Noise(Math.random())
setSize()
setLines()
frameId = requestAnimationFrame(tick)
ro = new ResizeObserver(() => {
setSize()
setLines()
})
ro.observe(container)
})
onUnmounted(() => {
ro?.disconnect()
if (frameId !== null) cancelAnimationFrame(frameId)
})
</script>
<template>
<div ref="containerRef" class="relative size-full overflow-hidden rounded-[inherit] opacity-50">
<canvas ref="canvasRef" class="block size-full rounded-[inherit]" />
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AccordionRoot,
useForwardPropsEmits,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AccordionRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AccordionRoot
v-slot="slotProps"
data-slot="accordion"
v-bind="forwarded"
:class="cn('overflow-hidden rounded-2xl border flex w-full flex-col', props.class)"
>
<slot v-bind="slotProps" />
</AccordionRoot>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { AccordionContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-open:animate-accordion-down data-closed:animate-accordion-up px-4 text-sm overflow-hidden"
>
<div
:class="cn(
'pt-0 pb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4',
props.class,
)"
>
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { AccordionItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AccordionItem, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('data-open:bg-muted/50 not-last:border-b', props.class)"
>
<slot v-bind="slotProps" />
</AccordionItem>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import {
AccordionHeader,
AccordionTrigger,
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'**:data-[slot=accordion-trigger-icon]:text-muted-foreground gap-6 p-4 text-left text-sm font-medium hover:underline **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50',
props.class,
)
"
>
<slot />
<slot name="icon">
<Icon name="ph:caret-down-fill" data-slot="accordion-trigger-icon" class="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<Icon name="ph:caret-up-fill" data-slot="accordion-trigger-icon" class="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AlertVariants } from '.'
import { cn } from '@/lib/utils'
import { alertVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: AlertVariants['variant']
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-action"
:class="cn('absolute top-2.5 right-3', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('font-medium group-has-[>svg]/alert:col-start-2 cn-font-heading [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', props.class)"
>
<slot />
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Alert } from './Alert.vue'
export { default as AlertAction } from './AlertAction.vue'
export { default as AlertDescription } from './AlertDescription.vue'
export { default as AlertTitle } from './AlertTitle.vue'
export const alertVariants = cva('grid gap-0.5 rounded-2xl border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4 group/alert relative w-full', {
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive: 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
})
export type AlertVariants = VariantProps<typeof alertVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AvatarVariants } from '.'
import { AvatarRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
import { avatarVariants } from '.'
const props = defineProps<{
class?: HTMLAttributes['class']
size?: AvatarVariants['size']
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:data-size="size ?? 'default'"
:class="cn(avatarVariants({ size }), props.class)"
>
<slot />
</AvatarRoot>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="avatar-badge"
:class="cn(
'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
props.class,
)"
>
<slot />
</span>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AvatarFallback } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted text-muted-foreground rounded-sm flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs', props.class)"
>
<slot />
</AvatarFallback>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="avatar-group"
:class="cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
props.class,
)"
>
<slot />
</div>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="avatar-group-count"
:class="cn(
'bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background',
props.class,
)"
>
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from 'reka-ui'
import { AvatarImage } from 'reka-ui'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="rounded-sm aspect-square size-full object-cover"
>
<slot />
</AvatarImage>
</template>
+27
View File
@@ -0,0 +1,27 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarBadge } from './AvatarBadge.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export { default as AvatarGroup } from './AvatarGroup.vue'
export { default as AvatarGroupCount } from './AvatarGroupCount.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export const avatarVariants = cva(
'size-8 rounded-sm after:rounded-sm data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten',
{
variants: {
size: {
sm: '',
default: '',
lg: '',
},
},
defaultVariants: {
size: 'default',
},
},
)
export type AvatarVariants = VariantProps<typeof avatarVariants>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Primitive
data-slot="badge"
:data-variant="variant"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'h-5 gap-1 rounded-2xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive: 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20',
outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="cn('', props.class)"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('size-5 [&>svg]:size-4 flex items-center justify-center', props.class)"
>
<slot>
<Icon name="ph:dots-three-fill" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('gap-1.5 inline-flex items-center', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>(), {
as: 'a',
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground gap-1.5 text-sm sm:gap-2.5 flex flex-wrap items-center wrap-break-word', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<Icon name="ph:caret-right-fill" class="cn-rtl-flip" />
</slot>
</li>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from './Breadcrumb.vue'
export { default as BreadcrumbEllipsis } from './BreadcrumbEllipsis.vue'
export { default as BreadcrumbItem } from './BreadcrumbItem.vue'
export { default as BreadcrumbLink } from './BreadcrumbLink.vue'
export { default as BreadcrumbList } from './BreadcrumbList.vue'
export { default as BreadcrumbPage } from './BreadcrumbPage.vue'
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.vue'
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button',
})
</script>
<template>
<Primitive
data-slot="button"
:data-variant="variant"
:data-size="size"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
+35
View File
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
'focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px cursor-pointer [&_svg:not([class*=size-])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline: 'border-border bg-background dark:bg-transparent hover:bg-muted hover:text-foreground dark:hover:bg-input/30 aria-expanded:bg-muted aria-expanded:text-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
'default': 'h-8 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
'xs': 'h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*=size-])]:size-3',
'sm': 'h-7 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
'lg': 'h-9 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
'icon': 'size-8',
'icon-xs': 'size-6 [&_svg:not([class*=size-])]:size-3',
'icon-sm': 'size-7',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
class?: HTMLAttributes['class']
size?: 'default' | 'sm'
}>(), {
size: 'default',
})
</script>
<template>
<div
data-slot="card"
:data-size="size"
:class="cn('bg-card text-card-foreground ring-foreground/5 dark:ring-foreground/10 gap-(--card-spacing) overflow-hidden rounded-[min(var(--radius-4xl),24px)] py-(--card-spacing) text-sm shadow-sm ring-1 [--card-spacing:--spacing(5)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)] group/card flex flex-col', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-(--card-spacing)', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('rounded-b-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('gap-1.5 rounded-t-[min(var(--radius-4xl),24px)] px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', props.class)"
>
<slot />
</div>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
data-slot="card-title"
:class="cn('text-base font-medium cn-font-heading', props.class)"
>
<slot />
</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
@@ -0,0 +1,59 @@
<script lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ChartConfig } from '.'
import { useId } from 'reka-ui'
import { computed, toRefs } from 'vue'
import { cn } from '@/lib/utils'
import { provideChartContext } from '.'
import ChartStyle from './ChartStyle.vue'
</script>
<script setup lang="ts">
const props = defineProps<{
id?: HTMLAttributes['id']
class?: HTMLAttributes['class']
config: ChartConfig
cursor?: boolean
}>()
defineSlots<{
default: {
id: string
config: ChartConfig
}
}>()
const { config } = toRefs(props)
const uniqueId = useId()
const chartId = computed(() => `chart-${props.id || uniqueId.replace(/:/g, '')}`)
provideChartContext({
id: uniqueId,
config,
})
</script>
<template>
<div
data-slot="chart"
:data-chart="chartId"
:class="cn(
`cn-chart [&_.tick_text]:!fill-muted-foreground [&_.tick_line]:!stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex flex-col aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden [&_[data-vis-xy-container]]:h-full [&_[data-vis-single-container]]:h-full h-full [&_[data-vis-xy-container]]:w-full [&_[data-vis-single-container]]:w-full w-full `,
props.class,
)"
:style="{
'--vis-tooltip-padding': '0px',
'--vis-tooltip-background-color': 'transparent',
'--vis-tooltip-border-color': 'transparent',
'--vis-tooltip-text-color': 'none',
'--vis-tooltip-shadow-color': 'none',
'--vis-tooltip-backdrop-filter': 'none',
'--vis-crosshair-circle-stroke-color': '#0000',
'--vis-crosshair-line-stroke-width': cursor ? '1px' : '0px',
'--vis-font-family': 'var(--font-sans)',
}"
>
<slot :id="uniqueId" :config="config" />
<ChartStyle :id="chartId" />
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { useChart } from '.'
const props = withDefaults(defineProps<{
hideIcon?: boolean
nameKey?: string
verticalAlign?: 'bottom' | 'top'
// payload?: any[]
class?: HTMLAttributes['class']
}>(), {
verticalAlign: 'bottom',
})
const { id, config } = useChart()
const payload = computed(() => Object.entries(config.value).map(([key, value]) => {
return {
key: props.nameKey || key,
itemConfig: config.value[key],
}
}))
const containerSelector = ref('')
onMounted(() => {
containerSelector.value = `[data-chart="chart-${id}"]>[data-vis-xy-container]`
})
</script>
<template>
<div
v-if="containerSelector"
:class="cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
props.class,
)"
>
<div
v-for="{ key, itemConfig } in payload"
:key="key"
:class="cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
)"
>
<component :is="itemConfig?.icon" v-if="itemConfig?.icon" />
<div
v-else
class="h-2 w-2 shrink-0 rounded-xs"
:style="{
backgroundColor: itemConfig?.color,
}"
/>
{{ itemConfig?.label }}
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Primitive } from 'reka-ui'
import { computed } from 'vue'
import { THEMES, useChart } from '.'
defineProps<{
id?: HTMLAttributes['id']
}>()
const { config } = useChart()
const colorConfig = computed(() => {
return Object.entries(config.value).filter(
([, config]) => config.theme || config.color,
)
})
</script>
<template>
<Primitive
v-if="colorConfig.length"
as="style"
>
{{ Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color
= itemConfig.theme?.[theme as keyof typeof itemConfig.theme]
|| itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`,
)
.join("\n") }}
</Primitive>
</template>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { ChartConfig } from '.'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<{
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
labelFormatter?: (d: number | Date) => string
payload?: Record<string, any>
config?: ChartConfig
class?: HTMLAttributes['class']
color?: string
x?: number | Date
}>(), {
payload: () => ({}),
config: () => ({}),
indicator: 'dot',
})
// TODO: currently we use `createElement` and `render` to render the
// const chartContext = useChart(null)
const payload = computed(() => {
return Object.entries(props.payload).map(([key, value]) => {
// const key = `${props.nameKey || item.name || item.dataKey || "value"}`
const itemConfig = props.config[key]
const indicatorColor = props.config[key]?.color ?? props.payload.fill
return { key, value, itemConfig, indicatorColor }
}).filter(i => i.itemConfig)
})
const nestLabel = computed(() => Object.keys(props.payload).length === 1 && props.indicator !== 'dot')
const tooltipLabel = computed(() => {
if (props.hideLabel)
return null
if (props.labelFormatter && props.x !== undefined) {
return props.labelFormatter(props.x)
}
return props.labelKey ? props.config[props.labelKey]?.label || props.payload[props.labelKey] : props.x
})
</script>
<template>
<div
:class="cn(
'bg-popover text-popover-foreground ring-foreground/5 dark:ring-foreground/10 gap-1.5 rounded-xl px-2.5 py-1.5 text-xs shadow-lg ring-1 grid min-w-32 items-start',
props.class,
)"
>
<slot>
<div v-if="!nestLabel && tooltipLabel" class="font-medium">
{{ tooltipLabel }}
</div>
<div class="grid gap-1.5">
<div
v-for="{ value, itemConfig, indicatorColor, key } in payload"
:key="key"
:class="
cn('[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center')"
>
<component :is="itemConfig.icon" v-if="itemConfig?.icon" />
<template v-else-if="!hideIndicator">
<div
:class="cn(
'shrink-0 rounded-xs border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)"
:style="{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
}"
/>
</template>
<div :class="cn('flex flex-1 justify-between leading-none', nestLabel ? 'items-end' : 'items-center')">
<div class="grid gap-1.5">
<div v-if="nestLabel" class="font-medium">
{{ tooltipLabel }}
</div>
<span class="text-muted-foreground">
{{ itemConfig?.label || value }}
</span>
</div>
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
{{ value.toLocaleString() }}
</span>
</div>
</div>
</div>
</slot>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
import type { Component, Ref } from 'vue'
import { createContext } from 'reka-ui'
export { default as ChartContainer } from './ChartContainer.vue'
export { default as ChartLegendContent } from './ChartLegendContent.vue'
export { default as ChartTooltipContent } from './ChartTooltipContent.vue'
export { componentToString } from './utils'
// Format: { THEME_NAME: CSS_SELECTOR }
export const THEMES = { light: '', dark: '.dark' } as const
export type ChartConfig = {
[k in string]: {
label?: string | Component
icon?: string | Component
} & (
| { color?: string, theme?: never }
| { color?: never, theme: Record<keyof typeof THEMES, string> }
)
}
interface ChartContextProps {
id: string
config: Ref<ChartConfig>
}
export const [useChart, provideChartContext] = createContext<ChartContextProps>('Chart')
export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from '@unovis/vue'
+44
View File
@@ -0,0 +1,44 @@
import type { ChartConfig } from '.'
import { isClient } from '@vueuse/core'
import { useId } from 'reka-ui'
import { h, render } from 'vue'
// Simple cache using a Map to store serialized object keys
const cache = new Map<string, string>()
// Convert object to a consistent string key
function serializeKey(key: Record<string, any>): string {
return JSON.stringify(key, Object.keys(key).sort())
}
interface Constructor<P = any> {
__isFragment?: never
__isTeleport?: never
__isSuspense?: never
new (...args: any[]): {
$props: P
}
}
export function componentToString<P>(config: ChartConfig, component: Constructor<P>, props?: P) {
if (!isClient)
return
// This function will be called once during mount lifecycle
const id = useId()
// https://unovis.dev/docs/auxiliary/Crosshair#component-props
return (_data: any, x: number | Date) => {
const data = 'data' in _data ? _data.data : _data
const serializedKey = `${id}-${serializeKey(data)}`
const cachedContent = cache.get(serializedKey)
if (cachedContent)
return cachedContent
const vnode = h<unknown>(component, { ...props, payload: data, config, x })
const div = document.createElement('div')
render(vnode, div)
cache.set(serializedKey, div.innerHTML)
return div.innerHTML
}
}
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="cn('bg-input/90 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary border-transparent aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[5px] border transition-shadow group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Icon name="ph:check-bold" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Checkbox } from './Checkbox.vue'
@@ -0,0 +1,453 @@
<script setup lang="ts">
import type { HexColor, HslaColor, HsvaColor, RgbaColor } from "@uiw/color-convert";
import {
hexToHsva,
hslaToHsva,
hsvaToHex,
hsvaToHsla,
hsvaToRgba,
rgbaToHsva,
} from "@uiw/color-convert";
import { computed, ref, watch } from "vue";
import ContrastRatio from "./ContrastRatio.vue";
import ObjectColorInput from "./ObjectColorInput.vue";
export interface ColorPickerValue {
hex: string;
hsl: HslaColor;
hsla: HslaColor;
rgb: RgbaColor;
rgba: RgbaColor;
}
export interface ColorPickerProps {
value?: `#${string}` | HsvaColor | HslaColor | RgbaColor;
type?: "hsl" | "hsla" | "rgb" | "rgba" | "hex";
swatches?: HexColor[];
hideContrastRatio?: boolean;
hideDefaultSwatches?: boolean;
class?: string;
open?: boolean;
}
const props = withDefaults(defineProps<ColorPickerProps>(), {
type: "hsl",
swatches: () => [],
hideContrastRatio: false,
hideDefaultSwatches: false,
open: false,
});
const emit = defineEmits<{
"value-change": [value: ColorPickerValue];
"update:open": [value: boolean];
}>();
const colorType = ref(props.type);
const isDropdownOpen = ref(false);
const isOpen = ref(props.open);
const colorOptions = [
{ value: "hex", label: "HEX" },
{ value: "hsl", label: "HSL" },
{ value: "hsla", label: "HSLA" },
{ value: "rgb", label: "RGB" },
{ value: "rgba", label: "RGBA" },
];
function getColorAsHsva(color: ColorPickerProps["value"]): HsvaColor {
if (!color) return { h: 0, s: 0, v: 0, a: 1 };
if (typeof color === "string") {
return hexToHsva(color);
} else if ("h" in color && "s" in color && "v" in color) {
return color;
} else if ("r" in color) {
return rgbaToHsva(color);
} else {
return hslaToHsva(color);
}
}
const colorHsv = ref<HsvaColor>(getColorAsHsva(props.value));
watch(
() => props.value,
(newValue) => {
if (newValue) {
colorHsv.value = getColorAsHsva(newValue);
}
},
{ deep: true },
);
watch(
() => props.open,
(newValue) => {
isOpen.value = newValue;
},
);
watch(isOpen, (newValue) => {
emit("update:open", newValue);
});
const hexValue = computed(() => hsvaToHex(colorHsv.value));
const hslValue = computed(() => hsvaToHsla(colorHsv.value));
const rgbValue = computed(() => hsvaToRgba(colorHsv.value));
const defaultSwatches = ["#F8371A", "#F97C1B", "#FAC81C", "#3FD0B6", "#2CADF6", "#6462FC"];
const sortedSwatches = computed(() => {
const allSwatches = [...defaultSwatches, ...props.swatches];
return allSwatches.sort((a, b) => hexToHsva(a).h - hexToHsva(b).h);
});
const isDraggingSaturation = ref(false);
const isDraggingHue = ref(false);
const isDraggingAlpha = ref(false);
const saturationRef = ref<HTMLElement>();
const hueRef = ref<HTMLElement>();
const alphaRef = ref<HTMLElement>();
function handleValueChange(color: HsvaColor) {
colorHsv.value = color;
const hslColor = hsvaToHsla(color);
const rgbColor = hsvaToRgba(color);
emit("value-change", {
hex: hsvaToHex(color),
hsl: {
...hslColor,
a: Math.round(hslColor.a * 100) / 100,
},
hsla: {
...hslColor,
a: Math.round(hslColor.a * 100) / 100,
},
rgb: {
...rgbColor,
a: Math.round(rgbColor.a * 100) / 100,
},
rgba: {
...rgbColor,
a: Math.round(rgbColor.a * 100) / 100,
},
});
}
function updateFromHex(value: string | number) {
try {
const newColor = hexToHsva(String(value));
handleValueChange(newColor);
} catch (error) {
// Invalid hex color, ignore
}
}
function updateFromObject(value: HslaColor | RgbaColor) {
try {
const newColor = "r" in value ? rgbaToHsva(value) : hslaToHsva(value);
handleValueChange(newColor);
} catch (error) {
// Invalid color object, ignore
}
}
function setColorFromHex(hex: string) {
handleValueChange(hexToHsva(hex));
}
function toggleDropdown() {
isDropdownOpen.value = !isDropdownOpen.value;
}
function selectColorType(value: string) {
colorType.value = value as "hsl" | "hsla" | "rgb" | "rgba" | "hex";
isDropdownOpen.value = false;
}
function startSaturationDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingSaturation.value = true;
updateSaturation(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingSaturation.value) {
updateSaturation(e);
}
}
function handleEnd() {
isDraggingSaturation.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateSaturation(event: MouseEvent | TouchEvent) {
if (!saturationRef.value) return;
const rect = saturationRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const clientY = "touches" in event ? event.touches[0].clientY : event.clientY;
const relativeX = (clientX - rect.left) / rect.width;
const relativeY = (clientY - rect.top) / rect.height;
const x = Math.max(0.01, Math.min(0.99, relativeX));
const y = Math.max(0.01, Math.min(0.99, relativeY));
handleValueChange({
...colorHsv.value,
s: x * 100,
v: (1 - y) * 100,
});
}
function handleHueClick(event: MouseEvent) {
updateHue(event);
}
function startHueDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingHue.value = true;
updateHue(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingHue.value) {
updateHue(e);
}
}
function handleEnd() {
isDraggingHue.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateHue(event: MouseEvent | TouchEvent) {
if (!hueRef.value) return;
const rect = hueRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
handleValueChange({
...colorHsv.value,
h: x * 360,
});
}
function startAlphaDrag(event: MouseEvent | TouchEvent) {
event.preventDefault();
isDraggingAlpha.value = true;
updateAlpha(event);
function handleMove(e: MouseEvent | TouchEvent) {
if (isDraggingAlpha.value) {
updateAlpha(e);
}
}
function handleEnd() {
isDraggingAlpha.value = false;
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleEnd);
document.removeEventListener("touchmove", handleMove);
document.removeEventListener("touchend", handleEnd);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleEnd);
document.addEventListener("touchmove", handleMove);
document.addEventListener("touchend", handleEnd);
}
function updateAlpha(event: MouseEvent | TouchEvent) {
if (!alphaRef.value) return;
const rect = alphaRef.value.getBoundingClientRect();
const clientX = "touches" in event ? event.touches[0].clientX : event.clientX;
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
handleValueChange({
...colorHsv.value,
a: x,
});
}
</script>
<template>
<div class="relative">
<div @click="isOpen = !isOpen">
<slot />
</div>
<div
v-if="isOpen"
class="bg-popover absolute top-full left-0 z-50 mt-2 w-80 max-w-[calc(100vw-3rem)] rounded-md border p-4 shadow-md"
>
<div class="space-y-4">
<div
ref="saturationRef"
class="relative aspect-4/2 w-full cursor-crosshair rounded border"
:style="{
background: `linear-gradient(to right, #fff, hsl(${colorHsv.h}, 100%, 50%)), linear-gradient(to top, #000, transparent)`,
}"
@mousedown="startSaturationDrag"
@touchstart="startSaturationDrag"
>
<div
class="pointer-events-none absolute h-4 w-4 rounded-full border-2 border-white"
:style="{
left: `${colorHsv.s}%`,
top: `${100 - colorHsv.v}%`,
transform: 'translate(-50%, -50%)',
}"
/>
</div>
<div
ref="hueRef"
class="relative h-4 w-full cursor-pointer rounded"
:style="{
background:
'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)',
}"
@mousedown="startHueDrag"
@touchstart="startHueDrag"
@click="handleHueClick"
>
<div
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
:style="{
left: `${(colorHsv.h / 360) * 100}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
}"
/>
</div>
<div class="flex items-center gap-2">
<div class="relative">
<button
type="button"
class="border-input bg-background inline-flex h-8 w-20 items-center justify-between gap-2 rounded-md border px-2 py-1 text-xs font-medium uppercase"
@click="toggleDropdown"
>
<span>{{ colorType }}</span>
<Icon name="ph:caret-down-fill" class="size-3.5" />
</button>
<div
v-if="isDropdownOpen"
class="border-input bg-popover absolute top-full left-0 z-50 mt-1 w-full rounded-md border shadow-md"
>
<div class="p-1">
<button
v-for="option in colorOptions"
:key="option.value"
type="button"
class="hover:bg-accent hover:text-accent-foreground relative flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-xs font-medium uppercase select-none"
@click="selectColorType(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div class="flex grow">
<input
v-if="colorType === 'hex'"
:value="hexValue"
type="text"
class="border-input bg-background placeholder:text-muted-foreground flex h-8 w-full rounded-md border px-3 py-1 text-sm focus:outline-none"
placeholder="#000000"
@input="updateFromHex(($event.target as HTMLInputElement).value)"
/>
<ObjectColorInput
v-else
:value="colorType === 'hsl' || colorType === 'hsla' ? hslValue : rgbValue"
:label="colorType"
@value-change="updateFromObject"
/>
</div>
</div>
<div
v-if="colorType === 'rgba' || colorType === 'hsla'"
class="space-y-2"
>
<div
ref="alphaRef"
class="relative h-4 w-full cursor-pointer rounded"
:style="{
background: `linear-gradient(to right, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 0) 0%, rgba(${rgbValue.r}, ${rgbValue.g}, ${rgbValue.b}, 1) 100%), repeating-conic-gradient(#ccc 0% 25%, transparent 0% 50%) 50% / 8px 8px`,
}"
@mousedown="startAlphaDrag"
@touchstart="startAlphaDrag"
>
<div
class="border-foreground pointer-events-none absolute h-4 w-4 rounded-full border-2 bg-white"
:style="{
left: `${colorHsv.a * 100}%`,
transform: 'translate(-50%, -50%)',
top: '50%',
}"
/>
</div>
</div>
<div v-if="swatches.length > 0 || !hideDefaultSwatches">
<div class="bg-border h-px" />
<div
v-if="!hideDefaultSwatches"
class="flex flex-wrap justify-start gap-2 pt-2"
>
<button
v-for="color in sortedSwatches"
:key="`${color}-swatch`"
type="button"
class="ring-offset-background h-5 w-5 shrink-0 cursor-pointer rounded ring-2 ring-transparent ring-offset-1 transition-all duration-100 hover:ring-current"
:style="{ backgroundColor: color }"
:aria-label="`Set color to ${color}`"
@click="setColorFromHex(color)"
@keydown.enter="setColorFromHex(color)"
/>
</div>
</div>
<div v-if="!hideContrastRatio">
<div class="bg-border h-px" />
<ContrastRatio :color="colorHsv" />
</div>
</div>
</div>
<div
v-if="isOpen"
class="fixed inset-0 z-40"
@click="isOpen = false"
/>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import type { HsvaColor } from "@uiw/color-convert";
import { hsvaToRgba } from "@uiw/color-convert";
import { computed, ref, watch } from "vue";
export interface ContrastRatioProps {
color: HsvaColor;
}
const props = defineProps<ContrastRatioProps>();
const darkModeContrastRatio = ref(0);
const lightModeContrastValue = ref(0);
const rgbaColor = computed(() => hsvaToRgba(props.color));
const shouldUseWhiteText = computed(() => {
const rgb = rgbaColor.value;
// Calculate luminance of the color background
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const rLinear = r <= 0.03928 ? r / 12.92 : ((r + 0.055) / 1.055) ** 2.4;
const gLinear = g <= 0.03928 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
const bLinear = b <= 0.03928 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
const luminance = 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
// Use white text if the background is dark (luminance < 0.5)
return luminance < 0.5;
});
const currentContrastRatio = computed(() => {
// For accessibility, use the better of the two contrast ratios (dark or light mode)
// This gives the most accurate assessment of accessibility across different contexts
return Math.max(darkModeContrastRatio.value, lightModeContrastValue.value);
});
const isAccessible = computed(() => {
return {
aa: currentContrastRatio.value >= 4.5,
aaa: currentContrastRatio.value >= 7,
};
});
function calculateContrastRatios(color: HsvaColor) {
const rgb = hsvaToRgba(color);
function toSRGB(c: number) {
const channel = c / 255;
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
}
function alphaBlend(foreground: number, background: number, alpha: number) {
return foreground * alpha + background * (1 - alpha);
}
// Apply alpha blending for light background (white: 255)
const lightR = alphaBlend(rgb.r, 255, rgb.a);
const lightG = alphaBlend(rgb.g, 255, rgb.a);
const lightB = alphaBlend(rgb.b, 255, rgb.a);
// Apply alpha blending for dark background (dark gray: 32)
const darkR = alphaBlend(rgb.r, 32, rgb.a);
const darkG = alphaBlend(rgb.g, 32, rgb.a);
const darkB = alphaBlend(rgb.b, 32, rgb.a);
// Calculate luminance for light background composition
const lightRSRGB = toSRGB(lightR);
const lightGSRGB = toSRGB(lightG);
const lightBSRGB = toSRGB(lightB);
const lightLuminance = 0.2126 * lightRSRGB + 0.7152 * lightGSRGB + 0.0722 * lightBSRGB;
// Calculate luminance for dark background composition
const darkRSRGB = toSRGB(darkR);
const darkGSRGB = toSRGB(darkG);
const darkBSRGB = toSRGB(darkB);
const darkLuminance = 0.2126 * darkRSRGB + 0.7152 * darkGSRGB + 0.0722 * darkBSRGB;
// Text color luminances (what we're measuring contrast against)
const whiteTextLuminance = 1.0;
const blackTextLuminance = 0.0;
// Calculate contrast ratios: text against the color background (alpha-blended)
// For dark mode: white text against the dark-composited color
const darkModeRatio =
(Math.max(whiteTextLuminance, darkLuminance) + 0.05) /
(Math.min(whiteTextLuminance, darkLuminance) + 0.05);
// For light mode: black text against the light-composited color
const lightModeRatio =
(Math.max(blackTextLuminance, lightLuminance) + 0.05) /
(Math.min(blackTextLuminance, lightLuminance) + 0.05);
darkModeContrastRatio.value = Number(darkModeRatio.toFixed(2));
lightModeContrastValue.value = Number(lightModeRatio.toFixed(2));
}
watch(() => props.color, calculateContrastRatios, { immediate: true, deep: true });
</script>
<template>
<div class="flex items-center justify-between gap-4 pt-2 select-none">
<div class="flex items-center gap-4">
<div
class="flex size-10 items-center justify-center rounded border"
:style="{
backgroundColor: `rgba(${rgbaColor.r}, ${rgbaColor.g}, ${rgbaColor.b}, ${rgbaColor.a})`,
backgroundSize: '8px 8px',
}"
>
<span
class="font-medium"
:style="{
color: shouldUseWhiteText ? 'white' : 'black',
}"
>A</span
>
</div>
<div class="flex flex-col justify-between">
<span class="text-muted-foreground text-xs text-nowrap whitespace-nowrap">
Contrast Ratio
</span>
<span class="text-sm">{{ currentContrastRatio }}</span>
</div>
</div>
<div class="flex items-center justify-end gap-1 *:select-none">
<Badge
:variant="isAccessible.aa ? 'default' : 'secondary'"
class="gap-1"
>
<Icon name="ph:check-fill" v-if="isAccessible.aa" class="size-3" />
<Icon name="ph:x-fill" v-else class="size-3" />
AA
</Badge>
<Badge
:variant="isAccessible.aaa ? 'default' : 'secondary'"
class="gap-1"
>
<Icon name="ph:check-fill" v-if="isAccessible.aaa" class="size-3" />
<Icon name="ph:x-fill" v-else class="size-3" />
AAA
</Badge>
</div>
</div>
</template>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { HslaColor, RgbaColor } from "@uiw/color-convert";
import { computed } from "vue";
interface ObjectColorInputProps {
label: "hsl" | "hsla" | "rgb" | "rgba";
value: HslaColor | RgbaColor;
}
const props = defineProps<ObjectColorInputProps>();
const emit = defineEmits<{ "value-change": [value: HslaColor | RgbaColor] }>();
const inputValues = computed(() => {
return getInputValues(props.value);
});
function getInputValues(value: HslaColor | RgbaColor): number[] {
const isHsl = props.label === "hsl" || props.label === "hsla";
const hasAlpha = props.label === "hsla" || props.label === "rgba";
if (isHsl) {
const hsl = value as HslaColor;
const values = [Math.round(hsl.h), Math.round(hsl.s), Math.round(hsl.l)];
if (hasAlpha) {
values.push(Math.round(hsl.a * 100));
}
return values;
} else {
const rgb = value as RgbaColor;
const values = [Math.round(rgb.r), Math.round(rgb.g), Math.round(rgb.b)];
if (hasAlpha) {
values.push(Math.round(rgb.a * 100));
}
return values;
}
}
function getInputClass(index: number, totalInputs: number): string {
const baseClass =
"flex-1 w-0 h-8 px-2 py-1 text-xs border border-input bg-background text-center overflow-hidden focus:outline-none focus-visible:outline-none focus:ring-0 focus-visible:ring-0";
const isFirst = index === 0;
const isLast = index === totalInputs - 1;
const isMiddle = !isFirst && !isLast;
let positionClasses = "";
if (isFirst) {
positionClasses = "rounded-l-md -mr-px";
} else if (isLast) {
positionClasses = "rounded-r-md";
} else if (isMiddle) {
positionClasses = "rounded-none -mr-px";
}
return `${baseClass} ${positionClasses}`;
}
const inputs = computed(() => {
const isHsl = props.label === "hsl" || props.label === "hsla";
const hasAlpha = props.label === "hsla" || props.label === "rgba";
let inputsData;
if (isHsl) {
const hsl = props.value as HslaColor;
inputsData = [
{ key: "h", value: Math.round(hsl.h), min: 0, max: 360, prop: "h" },
{ key: "s", value: Math.round(hsl.s), min: 0, max: 100, prop: "s" },
{ key: "l", value: Math.round(hsl.l), min: 0, max: 100, prop: "l" },
];
if (hasAlpha) {
inputsData.push({ key: "a", value: Math.round(hsl.a * 100), min: 0, max: 100, prop: "a" });
}
} else {
const rgb = props.value as RgbaColor;
inputsData = [
{ key: "r", value: Math.round(rgb.r), min: 0, max: 255, prop: "r" },
{ key: "g", value: Math.round(rgb.g), min: 0, max: 255, prop: "g" },
{ key: "b", value: Math.round(rgb.b), min: 0, max: 255, prop: "b" },
];
if (hasAlpha) {
inputsData.push({ key: "a", value: Math.round(rgb.a * 100), min: 0, max: 100, prop: "a" });
}
}
return inputsData.map((input, index) => ({
...input,
class: getInputClass(index, inputsData.length),
}));
});
function handleChange(idx: number, event: Event) {
const target = event.target as HTMLInputElement;
const value = Number(target.value);
if (Number.isNaN(value)) return;
const input = inputs.value[idx];
const clampedValue = Math.max(input.min, Math.min(input.max, value));
const finalValue = input.prop === "a" ? clampedValue / 100 : clampedValue;
emit("value-change", { ...props.value, [input.prop]: finalValue } as HslaColor | RgbaColor);
}
</script>
<template>
<div class="flex w-full min-w-0">
<input
v-for="(input, idx) in inputs"
:key="input.key"
:value="inputValues[idx]"
:min="input.min"
:max="input.max"
:class="input.class"
step="1"
@input="handleChange(idx, $event)"
/>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
export { default as ColorPicker } from "./ColorPicker.vue";
export type { ColorPickerProps, ColorPickerValue } from "./ColorPicker.vue";
export { default as ContrastRatio } from "./ContrastRatio.vue";
export { default as ObjectColorInput } from "./ObjectColorInput.vue";
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'
import { reactive, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { provideCommandContext } from '.'
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: '',
highlightOnHover: true,
})
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: 'base' })
const filterState = reactive({
search: '',
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map() as Map<string, number>,
/** Set of groups with at least one visible item. */
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
// Do nothing, each item will know to show itself because search is empty
return
}
// Reset the groups
filterState.filtered.groups = new Set()
let itemCount = 0
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score)
itemCount++
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(() => filterState.search, () => {
filterItems()
})
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
data-slot="command"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground rounded-3xl p-1 flex size-full flex-col overflow-hidden', props.class)"
>
<slot />
</ListboxRoot>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Command from './Command.vue'
const props = withDefaults(defineProps<DialogRootProps & {
title?: string
description?: string
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(), {
title: 'Command Palette',
description: 'Search for a command to run...',
showCloseButton: false,
})
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-slot="slotProps" v-bind="forwarded">
<DialogContent
:class="cn('rounded-3xl! top-1/3 translate-y-0 overflow-hidden p-0', props.class)"
:show-close-button="showCloseButton"
>
<DialogHeader class="sr-only">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<Command>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import { useCommand } from '.'
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0,
)
</script>
<template>
<Primitive
v-if="isRender"
data-slot="command-empty"
v-bind="delegatedProps"
:class="cn('py-6 text-center text-sm', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { ListboxGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui'
import { computed, onMounted, onUnmounted } from 'vue'
import { cn } from '@/lib/utils'
import { provideCommandGroupContext, useCommand } from '.'
const props = defineProps<ListboxGroupProps & {
class?: HTMLAttributes['class']
heading?: string
}>()
const delegatedProps = reactiveOmit(props, 'class')
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => !filterState.search ? true : filterState.filtered.groups.has(id))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id))
allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
data-slot="command-group"
:class="cn('text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { ListboxFilterProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxFilter, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { InputGroup, InputGroupAddon } from '@/components/ui/input-group'
import { useCommand } from '.'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<ListboxFilterProps & {
class?: HTMLAttributes['class']
}>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div
data-slot="command-input-wrapper"
class="p-1 pb-0"
>
<InputGroup class="bg-input/50 h-8!">
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
auto-focus
:class="cn('w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
<InputGroupAddon>
<Icon name="ph:magnifying-glass-fill" class="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit, useCurrentElement } from '@vueuse/core'
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { useCommand, useCommandGroup } from '.'
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
}
else {
const filteredCurrentItem = filterState.filtered.items.get(id)
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true
}
// Check with filter
return filteredCurrentItem > 0
}
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement))
return
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? (props.value?.toString() ?? ''))
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
}
else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
data-slot="command-item"
:class="cn('data-highlighted:bg-muted data-highlighted:text-foreground data-highlighted:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 min-h-7 rounded-xl px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl [&_svg:not([class*=size-])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', props.class)"
@select="() => {
filterState.search = ''
}"
>
<slot />
<Icon name="ph:check-fill" class="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</ListboxItem>
</template>

Some files were not shown because too many files have changed in this diff Show More