This commit is contained in:
@@ -101,7 +101,7 @@ export default async function AboutPage({ params: { locale } }: AboutPageProps)
|
|||||||
{[t("processOne"), t("processTwo"), t("processThree")].map((step, index) => (
|
{[t("processOne"), t("processTwo"), t("processThree")].map((step, index) => (
|
||||||
<AppCard key={step} level={2}>
|
<AppCard key={step} level={2}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-pill bg-surface-1 text-xs font-semibold text-foreground/80">
|
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground/80">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-foreground/80">{step}</p>
|
<p className="text-sm text-foreground/80">{step}</p>
|
||||||
|
|||||||
@@ -189,11 +189,11 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
|
|||||||
<CardContent className="p-6 lg:p-8">
|
<CardContent className="p-6 lg:p-8">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="inline-flex items-center gap-2 text-sm font-medium text-surface-inverse-foreground/80">
|
<p className="inline-flex items-center gap-2 text-sm font-medium text-primary-foreground/80">
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
{t("ctaTitle")}
|
{t("ctaTitle")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 max-w-2xl text-sm text-surface-inverse-foreground/88 sm:text-base">
|
<p className="mt-2 max-w-2xl text-sm text-primary-foreground/88 sm:text-base">
|
||||||
{t("ctaText")}
|
{t("ctaText")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default async function PortfolioItemPage({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{item.coverImagePath ? (
|
{item.coverImagePath ? (
|
||||||
<div className="mt-6 overflow-hidden rounded-surface border border-border">
|
<div className="mt-6 overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<PortfolioImage
|
<PortfolioImage
|
||||||
src={item.coverImagePath}
|
src={item.coverImagePath}
|
||||||
alt={getLocalizedValue(item.title, localeKey)}
|
alt={getLocalizedValue(item.title, localeKey)}
|
||||||
@@ -180,7 +180,7 @@ export default async function PortfolioItemPage({
|
|||||||
alt={getLocalizedValue(section.title, localeKey)}
|
alt={getLocalizedValue(section.title, localeKey)}
|
||||||
width={1200}
|
width={1200}
|
||||||
height={720}
|
height={720}
|
||||||
className="mt-4 h-48 w-full rounded-nested object-cover"
|
className="mt-4 h-48 w-full rounded-md object-cover"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{section.linkUrl ? (
|
{section.linkUrl ? (
|
||||||
@@ -204,7 +204,7 @@ export default async function PortfolioItemPage({
|
|||||||
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
|
<h2 className="text-xl font-semibold text-foreground">{t("gallery")}</h2>
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
{item.assets.map((asset) => (
|
{item.assets.map((asset) => (
|
||||||
<div key={asset.id} className="overflow-hidden rounded-surface border border-border">
|
<div key={asset.id} className="overflow-hidden rounded-lg border border-border bg-card">
|
||||||
{asset.kind === "IMAGE" ? (
|
{asset.kind === "IMAGE" ? (
|
||||||
<PortfolioImage
|
<PortfolioImage
|
||||||
src={asset.filePath}
|
src={asset.filePath}
|
||||||
@@ -214,7 +214,7 @@ export default async function PortfolioItemPage({
|
|||||||
className="h-64 w-full object-cover"
|
className="h-64 w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-64 items-center justify-center bg-surface-1 p-6 text-center text-sm text-muted-foreground">
|
<div className="flex h-64 items-center justify-center bg-muted/40 p-6 text-center text-sm text-muted-foreground">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>{getLocalizedValue(asset.alt, localeKey)}</p>
|
<p>{getLocalizedValue(asset.alt, localeKey)}</p>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ export default async function PortfolioPage({
|
|||||||
<section className="flex flex-wrap gap-3">
|
<section className="flex flex-wrap gap-3">
|
||||||
<Link
|
<Link
|
||||||
href={getLocalizedPath(localeKey, "/portfolio")}
|
href={getLocalizedPath(localeKey, "/portfolio")}
|
||||||
className={`rounded-pill border px-4 py-2 text-sm transition-colors ${
|
className={`rounded-md border px-4 py-2 text-sm transition-colors ${
|
||||||
selectedCategory === ""
|
selectedCategory === ""
|
||||||
? "border-border-strong bg-foreground text-background"
|
? "border-input bg-primary text-primary-foreground"
|
||||||
: "border-border bg-background text-foreground/80 hover:border-border-strong hover:text-foreground"
|
: "border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t("all")}
|
{t("all")}
|
||||||
@@ -87,10 +87,10 @@ export default async function PortfolioPage({
|
|||||||
<Link
|
<Link
|
||||||
key={category.id}
|
key={category.id}
|
||||||
href={getLocalizedPath(localeKey, `/portfolio?category=${category.slug}`)}
|
href={getLocalizedPath(localeKey, `/portfolio?category=${category.slug}`)}
|
||||||
className={`rounded-pill border px-4 py-2 text-sm transition-colors ${
|
className={`rounded-md border px-4 py-2 text-sm transition-colors ${
|
||||||
selectedCategory === category.slug
|
selectedCategory === category.slug
|
||||||
? "border-border-strong bg-foreground text-background"
|
? "border-input bg-primary text-primary-foreground"
|
||||||
: "border-border bg-background text-foreground/80 hover:border-border-strong hover:text-foreground"
|
: "border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getLocalizedValue(category.name, localeKey)}
|
{getLocalizedValue(category.name, localeKey)}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default async function ProductPage({ params: { locale, slug } }: ProductP
|
|||||||
{[t("stepOne"), t("stepTwo"), t("stepThree")].map((step, index) => (
|
{[t("stepOne"), t("stepTwo"), t("stepThree")].map((step, index) => (
|
||||||
<AppCard key={step} level={2}>
|
<AppCard key={step} level={2}>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-pill bg-surface-1 text-xs font-semibold text-foreground/80">
|
<span className="mb-2 inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground/80">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-foreground/80">{step}</p>
|
<p className="text-sm text-foreground/80">{step}</p>
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ export default async function ComingSoonPage({
|
|||||||
<MotionFade className="w-full">
|
<MotionFade className="w-full">
|
||||||
<AppCard
|
<AppCard
|
||||||
level={3}
|
level={3}
|
||||||
className="mx-auto w-full max-w-4xl overflow-hidden border-border/70 bg-surface-1/88 shadow-none"
|
className="mx-auto w-full max-w-4xl overflow-hidden border-border/70 bg-card/95 shadow-sm"
|
||||||
>
|
>
|
||||||
<CardContent className="relative p-6 text-center sm:p-10 lg:p-14">
|
<CardContent className="relative p-6 text-center sm:p-10 lg:p-14">
|
||||||
<div className="pointer-events-none absolute -right-20 -top-20 h-48 w-48 rounded-full bg-brand-secondary/15 blur-3xl sm:h-64 sm:w-64" />
|
<div className="pointer-events-none absolute -right-20 -top-20 h-48 w-48 rounded-full bg-brand-secondary/15 blur-3xl sm:h-64 sm:w-64" />
|
||||||
<div className="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-brand-primary/15 blur-3xl sm:h-72 sm:w-72" />
|
<div className="pointer-events-none absolute -bottom-24 -left-16 h-56 w-56 rounded-full bg-brand-primary/15 blur-3xl sm:h-72 sm:w-72" />
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<p className="inline-flex items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
<p className="inline-flex items-center gap-2 rounded-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-brand-secondary" />
|
<Sparkles className="h-3.5 w-3.5 text-brand-secondary" />
|
||||||
{t("badge")}
|
{t("badge")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+54
-69
@@ -3,23 +3,23 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 210 20% 98%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222 30% 12%;
|
--foreground: 224 71.4% 4.1%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222 30% 12%;
|
--card-foreground: 224 71.4% 4.1%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222 30% 12%;
|
--popover-foreground: 224 71.4% 4.1%;
|
||||||
|
|
||||||
--primary: 214 84% 42%;
|
--primary: 222.2 47.4% 11.2%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 24% 94%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222 30% 12%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--muted: 210 20% 95%;
|
--muted: 210 40% 96.1%;
|
||||||
--muted-foreground: 215 16% 38%;
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--accent: 191 78% 42%;
|
--accent: 210 40% 96.1%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--destructive: 0 72% 47%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--surface-1: 0 0% 100%;
|
--surface-1: 0 0% 100%;
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
--surface-inverse: 222 34% 14%;
|
--surface-inverse: 222 34% 14%;
|
||||||
--surface-inverse-foreground: 210 40% 98%;
|
--surface-inverse-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--border: 214 22% 88%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--border-strong: 214 20% 80%;
|
--border-strong: 214.3 31.8% 85%;
|
||||||
--input: 214 20% 84%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 214 84% 42%;
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
--status-success: 145 63% 42%;
|
--status-success: 145 63% 42%;
|
||||||
--status-success-soft: 145 58% 92%;
|
--status-success-soft: 145 58% 92%;
|
||||||
@@ -40,20 +40,20 @@
|
|||||||
|
|
||||||
--brand-primary: 214 84% 42%;
|
--brand-primary: 214 84% 42%;
|
||||||
--brand-secondary: 191 78% 42%;
|
--brand-secondary: 191 78% 42%;
|
||||||
--ambient-primary-alpha: 0.18;
|
--ambient-primary-alpha: 0.08;
|
||||||
--ambient-secondary-alpha: 0.14;
|
--ambient-secondary-alpha: 0.06;
|
||||||
--ambient-grid-alpha: 0.4;
|
--ambient-grid-alpha: 0;
|
||||||
|
|
||||||
--radius-surface: 0rem;
|
--radius-surface: 0rem;
|
||||||
--radius-nested: 0rem;
|
--radius-nested: 0rem;
|
||||||
--radius-pill: 9999px;
|
--radius-pill: 0px;
|
||||||
|
|
||||||
--shadow-xs: 0 1px 2px hsl(220 26% 18% / 0.05);
|
--shadow-xs: 0 1px 2px hsl(220 26% 18% / 0.05);
|
||||||
--shadow-sm: 0 12px 30px -20px hsl(220 32% 18% / 0.16);
|
--shadow-sm: 0 1px 3px hsl(220 26% 18% / 0.1), 0 1px 2px -1px hsl(220 26% 18% / 0.1);
|
||||||
--shadow-md: 0 22px 48px -28px hsl(220 32% 18% / 0.2);
|
--shadow-md: 0 4px 6px -1px hsl(220 26% 18% / 0.1), 0 2px 4px -2px hsl(220 26% 18% / 0.1);
|
||||||
--shadow-lg: 0 28px 70px -34px hsl(220 32% 18% / 0.28);
|
--shadow-lg: 0 10px 15px -3px hsl(220 26% 18% / 0.12), 0 4px 6px -4px hsl(220 26% 18% / 0.12);
|
||||||
--shadow-card: 0 20px 46px -30px hsl(220 32% 18% / 0.2);
|
--shadow-card: 0 1px 3px hsl(220 26% 18% / 0.1), 0 1px 2px -1px hsl(220 26% 18% / 0.08);
|
||||||
--shadow-panel: 0 18px 50px -32px hsl(220 32% 18% / 0.18);
|
--shadow-panel: 0 4px 6px -1px hsl(220 26% 18% / 0.1), 0 2px 4px -2px hsl(220 26% 18% / 0.08);
|
||||||
--shadow-sidebar: inset 0 1px 0 hsl(0 0% 100% / 0.7);
|
--shadow-sidebar: inset 0 1px 0 hsl(0 0% 100% / 0.7);
|
||||||
|
|
||||||
--focus-ring: 0 0 0 3px hsl(var(--ring) / 0.18);
|
--focus-ring: 0 0 0 3px hsl(var(--ring) / 0.18);
|
||||||
@@ -77,23 +77,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222 33% 8%;
|
--background: 224 71.4% 4.1%;
|
||||||
--foreground: 210 25% 92%;
|
--foreground: 210 20% 98%;
|
||||||
|
|
||||||
--card: 222 28% 11%;
|
--card: 224 71.4% 4.1%;
|
||||||
--card-foreground: 210 25% 92%;
|
--card-foreground: 210 20% 98%;
|
||||||
--popover: 222 28% 11%;
|
--popover: 224 71.4% 4.1%;
|
||||||
--popover-foreground: 210 25% 92%;
|
--popover-foreground: 210 20% 98%;
|
||||||
|
|
||||||
--primary: 205 88% 64%;
|
--primary: 210 20% 98%;
|
||||||
--primary-foreground: 222 33% 8%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 222 18% 18%;
|
--secondary: 215 27.9% 16.9%;
|
||||||
--secondary-foreground: 210 25% 92%;
|
--secondary-foreground: 210 20% 98%;
|
||||||
--muted: 222 18% 16%;
|
--muted: 215 27.9% 16.9%;
|
||||||
--muted-foreground: 215 16% 72%;
|
--muted-foreground: 217.9 10.6% 64.9%;
|
||||||
--accent: 190 76% 57%;
|
--accent: 215 27.9% 16.9%;
|
||||||
--accent-foreground: 222 33% 8%;
|
--accent-foreground: 210 20% 98%;
|
||||||
--destructive: 0 72% 58%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--surface-1: 222 28% 11%;
|
--surface-1: 222 28% 11%;
|
||||||
@@ -102,10 +102,10 @@
|
|||||||
--surface-inverse: 210 40% 98%;
|
--surface-inverse: 210 40% 98%;
|
||||||
--surface-inverse-foreground: 222 33% 8%;
|
--surface-inverse-foreground: 222 33% 8%;
|
||||||
|
|
||||||
--border: 221 18% 21%;
|
--border: 215 27.9% 16.9%;
|
||||||
--border-strong: 221 18% 28%;
|
--border-strong: 215 20.2% 25%;
|
||||||
--input: 221 18% 26%;
|
--input: 215 27.9% 16.9%;
|
||||||
--ring: 205 88% 64%;
|
--ring: 216 12.2% 83.9%;
|
||||||
|
|
||||||
--status-success: 145 68% 66%;
|
--status-success: 145 68% 66%;
|
||||||
--status-success-soft: 145 36% 18%;
|
--status-success-soft: 145 36% 18%;
|
||||||
@@ -114,16 +114,16 @@
|
|||||||
|
|
||||||
--brand-primary: 205 88% 64%;
|
--brand-primary: 205 88% 64%;
|
||||||
--brand-secondary: 190 76% 57%;
|
--brand-secondary: 190 76% 57%;
|
||||||
--ambient-primary-alpha: 0.22;
|
--ambient-primary-alpha: 0.08;
|
||||||
--ambient-secondary-alpha: 0.18;
|
--ambient-secondary-alpha: 0.06;
|
||||||
--ambient-grid-alpha: 0.32;
|
--ambient-grid-alpha: 0;
|
||||||
|
|
||||||
--shadow-xs: 0 1px 2px hsl(220 50% 2% / 0.42);
|
--shadow-xs: 0 1px 2px hsl(220 50% 2% / 0.32);
|
||||||
--shadow-sm: 0 14px 30px -20px hsl(220 50% 2% / 0.48);
|
--shadow-sm: 0 1px 3px hsl(220 50% 2% / 0.28), 0 1px 2px -1px hsl(220 50% 2% / 0.28);
|
||||||
--shadow-md: 0 24px 48px -28px hsl(220 50% 2% / 0.58);
|
--shadow-md: 0 4px 6px -1px hsl(220 50% 2% / 0.32), 0 2px 4px -2px hsl(220 50% 2% / 0.32);
|
||||||
--shadow-lg: 0 34px 78px -34px hsl(220 50% 2% / 0.72);
|
--shadow-lg: 0 10px 15px -3px hsl(220 50% 2% / 0.36), 0 4px 6px -4px hsl(220 50% 2% / 0.36);
|
||||||
--shadow-card: 0 20px 52px -28px hsl(220 50% 2% / 0.55);
|
--shadow-card: 0 1px 3px hsl(220 50% 2% / 0.28), 0 1px 2px -1px hsl(220 50% 2% / 0.28);
|
||||||
--shadow-panel: 0 22px 56px -32px hsl(220 50% 2% / 0.52);
|
--shadow-panel: 0 4px 6px -1px hsl(220 50% 2% / 0.32), 0 2px 4px -2px hsl(220 50% 2% / 0.32);
|
||||||
--shadow-sidebar: inset 0 1px 0 hsl(210 25% 92% / 0.06);
|
--shadow-sidebar: inset 0 1px 0 hsl(210 25% 92% / 0.06);
|
||||||
|
|
||||||
--sidebar-background: 222 25% 10%;
|
--sidebar-background: 222 25% 10%;
|
||||||
@@ -153,22 +153,7 @@ html.dark {
|
|||||||
body {
|
body {
|
||||||
@apply min-h-screen text-foreground antialiased;
|
@apply min-h-screen text-foreground antialiased;
|
||||||
background-color: hsl(var(--background));
|
background-color: hsl(var(--background));
|
||||||
background-image:
|
background-image: none;
|
||||||
radial-gradient(
|
|
||||||
circle at top,
|
|
||||||
hsl(var(--brand-primary) / var(--ambient-primary-alpha)),
|
|
||||||
transparent 34%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
circle at bottom right,
|
|
||||||
hsl(var(--brand-secondary) / var(--ambient-secondary-alpha)),
|
|
||||||
transparent 30%
|
|
||||||
),
|
|
||||||
linear-gradient(hsl(var(--border) / var(--ambient-grid-alpha)) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, hsl(var(--border) / var(--ambient-grid-alpha)) 1px, transparent 1px);
|
|
||||||
background-size: auto, auto, 24px 24px, 24px 24px;
|
|
||||||
background-position: center top, right bottom, center center, center center;
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|||||||
+47
-20
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import { ExternalLink, ImageIcon, Trash2 } from "lucide-react";
|
import { ExternalLink, FileType2, ImageIcon, Link2, Tag, Trash2, Upload } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -12,6 +12,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminMediaAssets } from "@/lib/media";
|
import { getAdminMediaAssets } from "@/lib/media";
|
||||||
|
|
||||||
@@ -94,31 +101,51 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
|
<form action={createMediaAssetAction} className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="label">{copy.label}</Label>
|
<Label htmlFor="label" className="sr-only">
|
||||||
<Input id="label" name="label" required />
|
{copy.label}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Tag className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id="label" name="label" required placeholder={copy.label} className="pl-9" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="kind">{copy.kind}</Label>
|
<Label htmlFor="kind" className="sr-only">
|
||||||
<select
|
{copy.kind}
|
||||||
id="kind"
|
</Label>
|
||||||
name="kind"
|
<div className="relative">
|
||||||
defaultValue="IMAGE"
|
<FileType2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
<Select name="kind" defaultValue="IMAGE">
|
||||||
>
|
<SelectTrigger id="kind" className="pl-9">
|
||||||
<option value="IMAGE">IMAGE</option>
|
<SelectValue placeholder={copy.kind} />
|
||||||
<option value="DOCUMENT">DOCUMENT</option>
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
<SelectItem value="IMAGE">IMAGE</SelectItem>
|
||||||
|
<SelectItem value="DOCUMENT">DOCUMENT</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor="file">{copy.uploadFile}</Label>
|
<Label htmlFor="file" className="sr-only">
|
||||||
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" />
|
{copy.uploadFile}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id="file" name="file" type="file" accept="image/*,.svg,.pdf" className="pl-9 file:mr-3" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor="externalUrl">{copy.externalUrl}</Label>
|
<Label htmlFor="externalUrl" className="sr-only">
|
||||||
<Input id="externalUrl" name="externalUrl" placeholder="https://example.com/image.jpg" />
|
{copy.externalUrl}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id="externalUrl" name="externalUrl" placeholder={copy.externalUrl} className="pl-9" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
@@ -146,11 +173,11 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{asset.kind === "IMAGE" ? (
|
{asset.kind === "IMAGE" ? (
|
||||||
<div className="overflow-hidden rounded-surface border border-border bg-surface-1">
|
<div className="overflow-hidden rounded-lg border bg-card">
|
||||||
<img src={asset.url} alt={asset.label} className="h-48 w-full object-cover" />
|
<img src={asset.url} alt={asset.label} className="h-48 w-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-surface border border-border bg-surface-1 px-4 py-6 text-sm text-muted-foreground">
|
<div className="rounded-lg border bg-card px-4 py-6 text-sm text-muted-foreground">
|
||||||
{asset.fileName}
|
{asset.fileName}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -166,7 +193,7 @@ export default async function RootMediaPage({ searchParams }: RootMediaPageProps
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{asset.usages.length > 0 ? (
|
{asset.usages.length > 0 ? (
|
||||||
<div className="space-y-2 rounded-nested border border-border bg-surface-1 px-4 py-3 text-xs text-muted-foreground">
|
<div className="space-y-2 rounded-md border bg-muted/40 px-4 py-3 text-xs text-muted-foreground">
|
||||||
{asset.usages.map((usage) => (
|
{asset.usages.map((usage) => (
|
||||||
<p key={usage.id}>
|
<p key={usage.id}>
|
||||||
{usage.usageType} / {usage.entityType} / {usage.fieldKey}
|
{usage.usageType} / {usage.entityType} / {usage.fieldKey}
|
||||||
|
|||||||
+20
-6
@@ -171,32 +171,46 @@ export default async function RootPage({ searchParams }: RootPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!authConfigured ? (
|
{!authConfigured ? (
|
||||||
<p className="mb-4 rounded-nested border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<p className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
{copy.configMissing}
|
{copy.configMissing}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!basicConfigured ? (
|
{!basicConfigured ? (
|
||||||
<p className="mb-4 rounded-nested border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
<p className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
{copy.basicAuthMissing}
|
{copy.basicAuthMissing}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{searchParams?.error === "invalid" ? (
|
{searchParams?.error === "invalid" ? (
|
||||||
<p className="mb-4 rounded-nested border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
|
<p className="mb-4 rounded-md border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
|
||||||
{copy.invalidLogin}
|
{copy.invalidLogin}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{searchParams?.error === "locked" || lockState.locked ? (
|
{searchParams?.error === "locked" || lockState.locked ? (
|
||||||
<p className="mb-4 rounded-nested border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
|
<p className="mb-4 rounded-md border border-status-warning/30 bg-status-warning-soft px-3 py-2 text-sm text-status-warning">
|
||||||
{copy.lockedLogin}
|
{copy.lockedLogin}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<form action={loginAction} className="space-y-3">
|
<form action={loginAction} className="space-y-3">
|
||||||
<Label htmlFor="password">{copy.passwordLabel}</Label>
|
<div className="space-y-2">
|
||||||
<Input id="password" type="password" name="password" required />
|
<Label htmlFor="password" className="sr-only">
|
||||||
|
{copy.passwordLabel}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
placeholder={copy.passwordLabel}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!authConfigured || !basicConfigured || lockState.locked}
|
disabled={!authConfigured || !basicConfigured || lockState.locked}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { FileText, Hash, Text } from "lucide-react";
|
||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { FlashMessage } from "@/components/root/flash-message";
|
import { FlashMessage } from "@/components/root/flash-message";
|
||||||
@@ -7,6 +8,7 @@ import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
|||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -106,12 +108,18 @@ export default async function RootPortfolioCategoriesPage({
|
|||||||
>
|
>
|
||||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="create-slug">Slug</Label>
|
<Label htmlFor="create-slug" className="sr-only">Slug</Label>
|
||||||
<Input id="create-slug" name="slug" required />
|
<div className="relative">
|
||||||
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id="create-slug" name="slug" required placeholder="Slug" className="pl-9" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="create-sortOrder">{copy.sortOrder}</Label>
|
<Label htmlFor="create-sortOrder" className="sr-only">{copy.sortOrder}</Label>
|
||||||
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required />
|
<div className="relative">
|
||||||
|
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id="create-sortOrder" name="sortOrder" type="number" min="0" defaultValue="0" required placeholder={copy.sortOrder} className="pl-9" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
@@ -126,29 +134,37 @@ export default async function RootPortfolioCategoriesPage({
|
|||||||
|
|
||||||
{locales.map((locale) => (
|
{locales.map((locale) => (
|
||||||
<TabsContent key={locale.key} value={locale.key}>
|
<TabsContent key={locale.key} value={locale.key}>
|
||||||
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
|
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
|
||||||
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
|
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`create-name-${locale.key}`}>{`Name ${locale.label}`}</Label>
|
<Label htmlFor={`create-name-${locale.key}`} className="sr-only">{`Name ${locale.label}`}</Label>
|
||||||
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required />
|
<div className="relative">
|
||||||
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id={`create-name-${locale.key}`} name={`name${locale.key}`} required placeholder={`Name ${locale.label}`} className="pl-9" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor={`create-description-${locale.key}`}>{`${copy.description} ${locale.label}`}</Label>
|
<Label htmlFor={`create-description-${locale.key}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`create-description-${locale.key}`}
|
id={`create-description-${locale.key}`}
|
||||||
name={`description${locale.key}`}
|
name={`description${locale.key}`}
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
|
placeholder={`${copy.description} ${locale.label}`}
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm md:col-span-2">
|
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm md:col-span-2">
|
||||||
<input type="checkbox" name="isActive" defaultChecked />
|
<Checkbox name="isActive" defaultChecked />
|
||||||
{copy.active}
|
{copy.active}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -175,12 +191,17 @@ export default async function RootPortfolioCategoriesPage({
|
|||||||
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
<input type="hidden" name="redirectPath" value="/root/portfolio/categories" />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`slug-${category.id}`}>Slug</Label>
|
<Label htmlFor={`slug-${category.id}`} className="sr-only">Slug</Label>
|
||||||
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required />
|
<div className="relative">
|
||||||
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input id={`slug-${category.id}`} name="slug" defaultValue={category.slug} required placeholder="Slug" className="pl-9" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`sortOrder-${category.id}`}>{copy.sortOrder}</Label>
|
<Label htmlFor={`sortOrder-${category.id}`} className="sr-only">{copy.sortOrder}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Hash className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={`sortOrder-${category.id}`}
|
id={`sortOrder-${category.id}`}
|
||||||
name="sortOrder"
|
name="sortOrder"
|
||||||
@@ -188,8 +209,11 @@ export default async function RootPortfolioCategoriesPage({
|
|||||||
min="0"
|
min="0"
|
||||||
defaultValue={category.sortOrder}
|
defaultValue={category.sortOrder}
|
||||||
required
|
required
|
||||||
|
placeholder={copy.sortOrder}
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Tabs defaultValue="Ar">
|
<Tabs defaultValue="Ar">
|
||||||
@@ -208,36 +232,46 @@ export default async function RootPortfolioCategoriesPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TabsContent key={`${category.id}-content-${locale.key}`} value={locale.key}>
|
<TabsContent key={`${category.id}-content-${locale.key}`} value={locale.key}>
|
||||||
<div className="grid gap-4 rounded-surface border border-border p-4 md:grid-cols-2">
|
<div className="grid gap-4 rounded-lg border bg-card p-4 shadow-sm md:grid-cols-2">
|
||||||
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
|
<div className="md:col-span-2 text-sm text-muted-foreground">{locale.hint}</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${nameKey}-${category.id}`}>{`Name ${locale.label}`}</Label>
|
<Label htmlFor={`${nameKey}-${category.id}`} className="sr-only">{`Name ${locale.label}`}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Text className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={`${nameKey}-${category.id}`}
|
id={`${nameKey}-${category.id}`}
|
||||||
name={nameKey}
|
name={nameKey}
|
||||||
defaultValue={category.name[lowerLocale]}
|
defaultValue={category.name[lowerLocale]}
|
||||||
required
|
required
|
||||||
|
placeholder={`Name ${locale.label}`}
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label htmlFor={`${descriptionKey}-${category.id}`}>{`${copy.description} ${locale.label}`}</Label>
|
<Label htmlFor={`${descriptionKey}-${category.id}`} className="sr-only">{`${copy.description} ${locale.label}`}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<FileText className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${descriptionKey}-${category.id}`}
|
id={`${descriptionKey}-${category.id}`}
|
||||||
name={descriptionKey}
|
name={descriptionKey}
|
||||||
rows={4}
|
rows={4}
|
||||||
defaultValue={category.description[lowerLocale]}
|
defaultValue={category.description[lowerLocale]}
|
||||||
required
|
required
|
||||||
|
placeholder={`${copy.description} ${locale.label}`}
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
<label className="flex items-center gap-3 rounded-md border border-input bg-card px-4 py-3 text-sm">
|
||||||
<input type="checkbox" name="isActive" defaultChecked={category.isActive} />
|
<Checkbox name="isActive" defaultChecked={category.isActive} />
|
||||||
{copy.active}
|
{copy.active}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
+13
-192
@@ -1,26 +1,17 @@
|
|||||||
import { Boxes, ExternalLink, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { FlashMessage } from "@/components/root/flash-message";
|
import { FlashMessage } from "@/components/root/flash-message";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { PortfolioProjectsOverview } from "@/components/root/portfolio-projects-overview";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import {
|
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
||||||
getAdminPortfolioCategories,
|
|
||||||
getAdminPortfolioProjects,
|
|
||||||
getLocalizedValue,
|
|
||||||
} from "@/lib/portfolio";
|
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const copy = {
|
const copy = {
|
||||||
title: "Portfolio",
|
title: "Portfolio",
|
||||||
subtitle: "Verwaltung fuer Kategorien, Projekte und Inhalte.",
|
subtitle: "Zentrale Steuerung fuer Projekte, Inhalte und Medien.",
|
||||||
overview: "Uebersicht",
|
overview: "Uebersicht",
|
||||||
maintenance: "Wartungsmodus",
|
maintenance: "Wartungsmodus",
|
||||||
uiKit: "UI Kit",
|
uiKit: "UI Kit",
|
||||||
@@ -29,23 +20,6 @@ const copy = {
|
|||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
backToSite: "Zur Website",
|
||||||
totalCategories: "Kategorien",
|
|
||||||
totalProjects: "Projekte",
|
|
||||||
publishedProjects: "Veroeffentlicht",
|
|
||||||
newProject: "Neues Projekt",
|
|
||||||
newCategory: "Neue Kategorie",
|
|
||||||
category: "Kategorie",
|
|
||||||
all: "Alle",
|
|
||||||
status: "Status",
|
|
||||||
draft: "Entwurf",
|
|
||||||
published: "Veroeffentlicht",
|
|
||||||
filter: "Filtern",
|
|
||||||
sort: "Sortierung",
|
|
||||||
previewSet: "Preview Link gesetzt",
|
|
||||||
previewMissing: "Kein Preview Link",
|
|
||||||
editProject: "Projekt bearbeiten",
|
|
||||||
openProject: "Projekt ansehen",
|
|
||||||
empty: "Keine Projekte fuer die aktuellen Filter gefunden.",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type RootPortfolioPageProps = {
|
type RootPortfolioPageProps = {
|
||||||
@@ -69,17 +43,19 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
|
|||||||
redirect("/root");
|
redirect("/root");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedCategory = searchParams?.category && searchParams.category !== "__all__"
|
||||||
|
? searchParams.category
|
||||||
|
: "";
|
||||||
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
|
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
|
||||||
? searchParams.status
|
? searchParams.status
|
||||||
: "all";
|
: "all";
|
||||||
const [categories, projects] = await Promise.all([
|
const [categories, projects] = await Promise.all([
|
||||||
getAdminPortfolioCategories(),
|
getAdminPortfolioCategories(),
|
||||||
getAdminPortfolioProjects({
|
getAdminPortfolioProjects({
|
||||||
categoryId: searchParams?.category || undefined,
|
categoryId: selectedCategory || undefined,
|
||||||
status: selectedStatus,
|
status: selectedStatus,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const publishedProjects = projects.filter((project) => project.isPublished).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootDashboardShell
|
<RootDashboardShell
|
||||||
@@ -89,22 +65,6 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
|
|||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
headerActions={
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/root/portfolio/projects/new">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
{copy.newProject}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link href="/root/portfolio/categories">
|
|
||||||
<Tags className="h-4 w-4" />
|
|
||||||
{copy.newCategory}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{searchParams?.success ? (
|
{searchParams?.success ? (
|
||||||
@@ -119,151 +79,12 @@ export default async function RootPortfolioPage({ searchParams }: RootPortfolioP
|
|||||||
</MotionFade>
|
</MotionFade>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-3">
|
<PortfolioProjectsOverview
|
||||||
{[
|
categories={categories}
|
||||||
{
|
projects={projects}
|
||||||
icon: Layers3,
|
selectedCategory={selectedCategory}
|
||||||
label: copy.totalCategories,
|
selectedStatus={selectedStatus}
|
||||||
value: categories.length,
|
/>
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: FolderKanban,
|
|
||||||
label: copy.totalProjects,
|
|
||||||
value: projects.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Boxes,
|
|
||||||
label: copy.publishedProjects,
|
|
||||||
value: publishedProjects,
|
|
||||||
},
|
|
||||||
].map((item, index) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MotionFade key={item.label} delay={index * 0.05}>
|
|
||||||
<AppCard level={2}>
|
|
||||||
<CardContent className="flex items-center gap-4 p-6">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-pill bg-surface-1">
|
|
||||||
<Icon className="h-5 w-5 text-brand-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-muted-foreground">{item.label}</p>
|
|
||||||
<p className="text-3xl font-semibold text-foreground">{item.value}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<MotionFade delay={0.15}>
|
|
||||||
<AppCard>
|
|
||||||
<CardContent className="p-6">
|
|
||||||
<form className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="category" className="text-sm font-medium text-foreground">
|
|
||||||
{copy.category}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="category"
|
|
||||||
name="category"
|
|
||||||
defaultValue={searchParams?.category ?? ""}
|
|
||||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
|
||||||
>
|
|
||||||
<option value="">{copy.all}</option>
|
|
||||||
{categories.map((category) => (
|
|
||||||
<option key={category.id} value={category.id}>
|
|
||||||
{category.name.de}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="status" className="text-sm font-medium text-foreground">
|
|
||||||
{copy.status}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="status"
|
|
||||||
name="status"
|
|
||||||
defaultValue={selectedStatus}
|
|
||||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
|
||||||
>
|
|
||||||
<option value="all">{copy.all}</option>
|
|
||||||
<option value="draft">{copy.draft}</option>
|
|
||||||
<option value="published">{copy.published}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<Button type="submit" variant="outline">
|
|
||||||
{copy.filter}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{projects.map((project, index) => (
|
|
||||||
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
|
|
||||||
<AppCard interactive>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<CardTitle>{getLocalizedValue(project.title, "de")}</CardTitle>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
|
||||||
{project.category.name.de}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span className="rounded-pill border border-border px-3 py-1">
|
|
||||||
{project.isPublished ? copy.published : copy.draft}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-pill border border-border px-3 py-1">
|
|
||||||
{project.projectYear}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-pill border border-border px-3 py-1">
|
|
||||||
{copy.sort} {project.sortOrder}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
|
||||||
<p>{project.slug}</p>
|
|
||||||
<p>{project.previewUrl ? copy.previewSet : copy.previewMissing}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link
|
|
||||||
href={getLocalizedPath("de", `/portfolio/${project.slug}`)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
{copy.openProject}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
|
||||||
<MotionFade delay={0.18}>
|
|
||||||
<AppCard>
|
|
||||||
<CardContent className="p-6 text-sm text-muted-foreground">
|
|
||||||
{copy.empty}
|
|
||||||
</CardContent>
|
|
||||||
</AppCard>
|
|
||||||
</MotionFade>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</RootDashboardShell>
|
</RootDashboardShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { FlashMessage } from "@/components/root/flash-message";
|
||||||
|
import { PortfolioProjectsOverview } from "@/components/root/portfolio-projects-overview";
|
||||||
|
import { RootDashboardShell } from "@/components/root/root-dashboard-shell";
|
||||||
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
title: "Portfolio Projekte",
|
||||||
|
subtitle: "Projektliste und Einstieg in die komplette Bearbeitung.",
|
||||||
|
overview: "Uebersicht",
|
||||||
|
maintenance: "Wartungsmodus",
|
||||||
|
uiKit: "UI Kit",
|
||||||
|
media: "Media",
|
||||||
|
siteSettings: "SEO",
|
||||||
|
portfolio: "Portfolio",
|
||||||
|
logout: "Ausloggen",
|
||||||
|
backToSite: "Zur Website",
|
||||||
|
};
|
||||||
|
|
||||||
type RootPortfolioProjectsPageProps = {
|
type RootPortfolioProjectsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
category?: string;
|
category?: string;
|
||||||
@@ -14,23 +34,60 @@ type RootPortfolioProjectsPageProps = {
|
|||||||
export default async function RootPortfolioProjectsPage({
|
export default async function RootPortfolioProjectsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: RootPortfolioProjectsPageProps) {
|
}: RootPortfolioProjectsPageProps) {
|
||||||
const params = new URLSearchParams();
|
if (!isAdminAuthenticated()) {
|
||||||
|
redirect("/root");
|
||||||
if (searchParams?.category) {
|
|
||||||
params.set("category", searchParams.category);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchParams?.status) {
|
async function logoutAction() {
|
||||||
params.set("status", searchParams.status);
|
"use server";
|
||||||
|
|
||||||
|
clearAdminSessionCookie();
|
||||||
|
redirect("/root");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchParams?.success) {
|
const selectedCategory = searchParams?.category && searchParams.category !== "__all__"
|
||||||
params.set("success", searchParams.success);
|
? searchParams.category
|
||||||
}
|
: "";
|
||||||
|
const selectedStatus = searchParams?.status === "draft" || searchParams?.status === "published"
|
||||||
|
? searchParams.status
|
||||||
|
: "all";
|
||||||
|
const [categories, projects] = await Promise.all([
|
||||||
|
getAdminPortfolioCategories(),
|
||||||
|
getAdminPortfolioProjects({
|
||||||
|
categoryId: selectedCategory || undefined,
|
||||||
|
status: selectedStatus,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (searchParams?.error) {
|
return (
|
||||||
params.set("error", searchParams.error);
|
<RootDashboardShell
|
||||||
}
|
copy={copy}
|
||||||
|
active="portfolio"
|
||||||
|
portfolioChild="projects"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<MotionFade delay={0.08}>
|
||||||
|
<FlashMessage type="success" message={searchParams.success} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
redirect(params.toString() ? `/root/portfolio?${params.toString()}` : "/root/portfolio");
|
{searchParams?.error ? (
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<FlashMessage type="error" message={searchParams.error} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<PortfolioProjectsOverview
|
||||||
|
categories={categories}
|
||||||
|
projects={projects}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
selectedStatus={selectedStatus}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function AppSidebar({
|
|||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-nested px-3 py-2 text-sm transition-colors",
|
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
|
||||||
item.active
|
item.active
|
||||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
: "text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function FloatingPreferences({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("fixed inset-x-0 top-0 z-50 px-4 pt-4 sm:px-6", className)}>
|
<div className={cn("fixed inset-x-0 top-0 z-50 px-4 pt-4 sm:px-6", className)}>
|
||||||
<div className="mx-auto flex w-full max-w-max items-center justify-center gap-2">
|
<div className="mx-auto flex w-full max-w-max items-center justify-center gap-2">
|
||||||
<ThemeToggle ariaLabel={t("themeToggle")} className="bg-surface-1/90" />
|
<ThemeToggle ariaLabel={t("themeToggle")} className="bg-background/90" />
|
||||||
<LocaleToggle locale={locale} />
|
<LocaleToggle locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
|
|||||||
const tFooter = useTranslations("footer");
|
const tFooter = useTranslations("footer");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-border bg-surface-2">
|
<footer className="border-t border-border bg-background">
|
||||||
<Container className="flex flex-col gap-4 py-8">
|
<Container className="flex flex-col gap-4 py-8">
|
||||||
<nav className="flex flex-wrap gap-x-5 gap-y-3 text-sm">
|
<nav className="flex flex-wrap gap-x-5 gap-y-3 text-sm">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href={getLocalizedPath(locale)}
|
href={getLocalizedPath(locale)}
|
||||||
className="inline-flex items-center rounded-nested border border-border bg-surface-1 px-3 py-2 shadow-xs"
|
className="inline-flex items-center rounded-md border border-input bg-background px-3 py-2 shadow-sm"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="/logos/light-primary.svg"
|
src="/logos/light-primary.svg"
|
||||||
@@ -115,7 +115,7 @@ export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
|
|||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
<div className="border-t border-border/80 bg-surface-1 md:hidden">
|
<div className="border-t border-border/80 bg-background md:hidden">
|
||||||
<Container className="py-4">
|
<Container className="py-4">
|
||||||
<nav className="flex flex-col gap-3">
|
<nav className="flex flex-col gap-3">
|
||||||
<NavLinks isAdmin={isAdmin} onNavigate={() => setIsOpen(false)} />
|
<NavLinks isAdmin={isAdmin} onNavigate={() => setIsOpen(false)} />
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function FlashMessage({
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-nested border px-4 py-3 text-sm",
|
"rounded-md border px-4 py-3 text-sm",
|
||||||
type === "success"
|
type === "success"
|
||||||
? "border-status-success/30 bg-status-success/10 text-status-success"
|
? "border-status-success/30 bg-status-success/10 text-status-success"
|
||||||
: "border-destructive/30 bg-destructive/10 text-destructive",
|
: "border-destructive/30 bg-destructive/10 text-destructive",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import type { MediaKind } from "@prisma/client";
|
import type { MediaKind } from "@prisma/client";
|
||||||
import { Check, Search } from "lucide-react";
|
import { Check, Link2, Search, Type, Upload } from "lucide-react";
|
||||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { MediaOption } from "@/lib/media";
|
import type { MediaOption } from "@/lib/media";
|
||||||
@@ -110,9 +110,9 @@ export function MediaFieldPicker({
|
|||||||
}, [serializedValue]);
|
}, [serializedValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-surface border border-border p-4">
|
<div className="space-y-3 rounded-lg border bg-card p-4 shadow-sm">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{title}</Label>
|
<Label className="sr-only">{title}</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{modeOptions.map((mode) => (
|
{modeOptions.map((mode) => (
|
||||||
<button
|
<button
|
||||||
@@ -128,10 +128,10 @@ export function MediaFieldPicker({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-pill border px-4 py-2 text-sm transition-colors",
|
"rounded-md border px-4 py-2 text-sm transition-colors",
|
||||||
value.mode === mode
|
value.mode === mode
|
||||||
? "border-border-strong bg-foreground text-background"
|
? "border-input bg-primary text-primary-foreground"
|
||||||
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
|
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{mode}
|
{mode}
|
||||||
@@ -150,7 +150,7 @@ export function MediaFieldPicker({
|
|||||||
isCleared: true,
|
isCleared: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="rounded-pill border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:border-destructive/50 hover:bg-destructive/5"
|
className="rounded-md border border-destructive/25 px-4 py-2 text-sm text-destructive transition-colors hover:bg-destructive/5"
|
||||||
>
|
>
|
||||||
{clearLabel}
|
{clearLabel}
|
||||||
</button>
|
</button>
|
||||||
@@ -167,37 +167,48 @@ export function MediaFieldPicker({
|
|||||||
|
|
||||||
{value.mode !== "library" ? (
|
{value.mode !== "library" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Label</Label>
|
<Label className="sr-only">Label</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={value.label}
|
value={value.label}
|
||||||
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
|
onChange={(event) => onChange({ ...value, label: event.target.value, isCleared: false })}
|
||||||
placeholder="Homepage Hero"
|
placeholder="Label"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{value.mode === "upload" ? (
|
{value.mode === "upload" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{fileLabel ?? fileFieldName}</Label>
|
<Label className="sr-only">{fileLabel ?? fileFieldName}</Label>
|
||||||
<Input name={fileFieldName} type="file" accept={accept} />
|
<div className="relative">
|
||||||
|
<Upload className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input name={fileFieldName} type="file" accept={accept} className="pl-9 file:mr-3" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{value.mode === "external" ? (
|
{value.mode === "external" ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{externalLabel ?? "External URL"}</Label>
|
<Label className="sr-only">{externalLabel ?? "External URL"}</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={value.url}
|
value={value.url}
|
||||||
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
|
onChange={(event) => onChange({ ...value, url: event.target.value, isCleared: false })}
|
||||||
placeholder="https://example.com/image.jpg"
|
placeholder={externalLabel ?? "External URL"}
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{value.mode === "library" ? (
|
{value.mode === "library" ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{libraryLabel ?? "Media Library"}</Label>
|
<Label className="sr-only">{libraryLabel ?? "Media Library"}</Label>
|
||||||
<div className="space-y-2 rounded-nested border border-border bg-surface-1 p-3">
|
<div className="space-y-2 rounded-md border bg-card p-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -227,10 +238,10 @@ export function MediaFieldPicker({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-3 rounded-nested border px-3 py-2 text-left transition-colors",
|
"flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors",
|
||||||
isActive
|
isActive
|
||||||
? "border-border-strong bg-background"
|
? "border-input bg-accent/40"
|
||||||
: "border-border bg-background/70 hover:border-border-strong hover:bg-background",
|
: "border-input bg-background hover:bg-accent/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{option.url ? (
|
{option.url ? (
|
||||||
@@ -247,7 +258,7 @@ export function MediaFieldPicker({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-nested border border-dashed border-border px-3 py-4 text-sm text-muted-foreground">
|
<div className="rounded-md border border-dashed border-input px-3 py-4 text-sm text-muted-foreground">
|
||||||
No media found.
|
No media found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -258,11 +269,11 @@ export function MediaFieldPicker({
|
|||||||
|
|
||||||
{previewUrl ? (
|
{previewUrl ? (
|
||||||
value.kind === "IMAGE" ? (
|
value.kind === "IMAGE" ? (
|
||||||
<div className="overflow-hidden rounded-nested border border-border bg-surface-1">
|
<div className="overflow-hidden rounded-md border bg-card">
|
||||||
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
|
<img src={previewUrl} alt={value.label || title} className="h-40 w-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-nested border border-border bg-surface-1 px-4 py-3 text-sm text-muted-foreground">
|
<div className="rounded-md border bg-card px-4 py-3 text-sm text-muted-foreground">
|
||||||
{previewUrl}
|
{previewUrl}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
|
import type { MediaKind, PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
|
||||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowUp,
|
||||||
|
BriefcaseBusiness,
|
||||||
|
CalendarRange,
|
||||||
|
FolderTree,
|
||||||
|
Hash,
|
||||||
|
Link2,
|
||||||
|
Plus,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { moveArrayItem } from "@/lib/array";
|
import { moveArrayItem } from "@/lib/array";
|
||||||
@@ -176,6 +195,8 @@ export function PortfolioProjectForm({
|
|||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
const [isFeatured, setIsFeatured] = useState(project?.isFeatured ?? false);
|
||||||
|
const [isPublished, setIsPublished] = useState(project?.isPublished ?? false);
|
||||||
|
|
||||||
const sectionsPayload = JSON.stringify(
|
const sectionsPayload = JSON.stringify(
|
||||||
sections.map((section, index) => ({
|
sections.map((section, index) => ({
|
||||||
@@ -205,39 +226,62 @@ export function PortfolioProjectForm({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="categoryId">Kategorie</Label>
|
<Label htmlFor="categoryId" className="sr-only">Kategorie</Label>
|
||||||
<select
|
<div className="relative">
|
||||||
id="categoryId"
|
<FolderTree className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Select
|
||||||
name="categoryId"
|
name="categoryId"
|
||||||
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
|
defaultValue={project?.category.id ?? categories[0]?.id ?? ""}
|
||||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
<SelectTrigger id="categoryId" className="pl-9">
|
||||||
|
<SelectValue placeholder="Kategorie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<SelectItem key={category.id} value={category.id}>
|
||||||
{category.name.de} / {category.name.en}
|
{category.name.de} / {category.name.en}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="slug">Slug</Label>
|
<Label htmlFor="slug" className="sr-only">Slug</Label>
|
||||||
<Input id="slug" name="slug" defaultValue={project?.slug ?? ""} required />
|
<div className="relative">
|
||||||
|
<Hash className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
defaultValue={project?.slug ?? ""}
|
||||||
|
placeholder="Slug"
|
||||||
|
className="pl-9"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="clientName">Kundenname</Label>
|
<Label htmlFor="clientName" className="sr-only">Kundenname</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<BriefcaseBusiness className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="clientName"
|
id="clientName"
|
||||||
name="clientName"
|
name="clientName"
|
||||||
defaultValue={project?.clientName ?? ""}
|
defaultValue={project?.clientName ?? ""}
|
||||||
|
placeholder="Kundenname"
|
||||||
|
className="pl-9"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="projectYear">Projektjahr</Label>
|
<Label htmlFor="projectYear" className="sr-only">Projektjahr</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<CalendarRange className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="projectYear"
|
id="projectYear"
|
||||||
name="projectYear"
|
name="projectYear"
|
||||||
@@ -245,32 +289,44 @@ export function PortfolioProjectForm({
|
|||||||
min="2000"
|
min="2000"
|
||||||
max="2100"
|
max="2100"
|
||||||
defaultValue={project?.projectYear ?? new Date().getFullYear()}
|
defaultValue={project?.projectYear ?? new Date().getFullYear()}
|
||||||
|
placeholder="Projektjahr"
|
||||||
|
className="pl-9"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="previewUrl">Preview URL</Label>
|
<Label htmlFor="previewUrl" className="sr-only">Preview URL</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Link2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="previewUrl"
|
id="previewUrl"
|
||||||
name="previewUrl"
|
name="previewUrl"
|
||||||
type="url"
|
type="url"
|
||||||
defaultValue={project?.previewUrl ?? ""}
|
defaultValue={project?.previewUrl ?? ""}
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="sortOrder">Sortierung</Label>
|
<Label htmlFor="sortOrder" className="sr-only">Sortierung</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Hash className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="sortOrder"
|
id="sortOrder"
|
||||||
name="sortOrder"
|
name="sortOrder"
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
defaultValue={project?.sortOrder ?? 0}
|
defaultValue={project?.sortOrder ?? 0}
|
||||||
|
placeholder="Sortierung"
|
||||||
|
className="pl-9"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<MediaFieldPicker
|
<MediaFieldPicker
|
||||||
@@ -284,13 +340,23 @@ export function PortfolioProjectForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
<label className="flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm">
|
||||||
<input type="checkbox" name="isFeatured" defaultChecked={project?.isFeatured ?? false} />
|
<Checkbox
|
||||||
|
name="isFeatured"
|
||||||
|
checked={isFeatured}
|
||||||
|
onCheckedChange={(checked) => setIsFeatured(checked === true)}
|
||||||
|
/>
|
||||||
|
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||||
Hervorgehoben
|
Hervorgehoben
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 rounded-nested border border-border px-4 py-3 text-sm">
|
<label className="flex items-center gap-3 rounded-md border border-border px-4 py-3 text-sm">
|
||||||
<input type="checkbox" name="isPublished" defaultChecked={project?.isPublished ?? false} />
|
<Checkbox
|
||||||
|
name="isPublished"
|
||||||
|
checked={isPublished}
|
||||||
|
onCheckedChange={(checked) => setIsPublished(checked === true)}
|
||||||
|
/>
|
||||||
|
<Sparkles className="h-4 w-4 text-muted-foreground" />
|
||||||
Veroeffentlicht
|
Veroeffentlicht
|
||||||
</label>
|
</label>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -411,25 +477,29 @@ export function PortfolioProjectForm({
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Typ</Label>
|
<Label>Typ</Label>
|
||||||
<select
|
<Select
|
||||||
value={section.type}
|
value={section.type}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
setSections((current) =>
|
setSections((current) =>
|
||||||
current.map((item, currentIndex) =>
|
current.map((item, currentIndex) =>
|
||||||
currentIndex === index
|
currentIndex === index
|
||||||
? { ...item, type: event.target.value as PortfolioSectionType }
|
? { ...item, type: value as PortfolioSectionType }
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
|
||||||
>
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Typ" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{sectionTypeOptions.map((type) => (
|
{sectionTypeOptions.map((type) => (
|
||||||
<option key={type} value={type}>
|
<SelectItem key={type} value={type}>
|
||||||
{type}
|
{type}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
@@ -457,7 +527,9 @@ export function PortfolioProjectForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
<Label>Link URL</Label>
|
<Label className="sr-only">Link URL</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Link2 className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={section.linkUrl}
|
value={section.linkUrl}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -467,8 +539,11 @@ export function PortfolioProjectForm({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
placeholder="Link URL"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{localeFieldConfig.map((locale) => (
|
{localeFieldConfig.map((locale) => (
|
||||||
<div key={`${locale.key}-title-${index}`} className="space-y-2">
|
<div key={`${locale.key}-title-${index}`} className="space-y-2">
|
||||||
@@ -575,32 +650,36 @@ export function PortfolioProjectForm({
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Typ</Label>
|
<Label>Typ</Label>
|
||||||
<select
|
<Select
|
||||||
value={asset.kind}
|
value={asset.kind}
|
||||||
onChange={(event) =>
|
onValueChange={(value) =>
|
||||||
setAssets((current) =>
|
setAssets((current) =>
|
||||||
current.map((item, currentIndex) =>
|
current.map((item, currentIndex) =>
|
||||||
currentIndex === index
|
currentIndex === index
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
kind: event.target.value as PortfolioAssetKind,
|
kind: value as PortfolioAssetKind,
|
||||||
media: {
|
media: {
|
||||||
...item.media,
|
...item.media,
|
||||||
kind: event.target.value as PortfolioAssetKind,
|
kind: value as PortfolioAssetKind,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex h-11 w-full rounded-pill border border-border bg-background px-4 text-sm text-foreground shadow-sm"
|
|
||||||
>
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Typ" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{assetKindOptions.map((kind) => (
|
{assetKindOptions.map((kind) => (
|
||||||
<option key={kind} value={kind}>
|
<SelectItem key={kind} value={kind}>
|
||||||
{kind}
|
{kind}
|
||||||
</option>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { Boxes, ExternalLink, Filter, FolderKanban, Layers3, Plus, Tags } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
|
import { PortfolioSubnav } from "@/components/root/portfolio-subnav";
|
||||||
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
|
import { getLocalizedValue, type PortfolioCategoryView, type PortfolioProjectView } from "@/lib/portfolio";
|
||||||
|
|
||||||
|
type PortfolioProjectsOverviewProps = {
|
||||||
|
categories: PortfolioCategoryView[];
|
||||||
|
projects: PortfolioProjectView[];
|
||||||
|
selectedCategory: string;
|
||||||
|
selectedStatus: "all" | "draft" | "published";
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = {
|
||||||
|
summary: "Portfolio cockpit",
|
||||||
|
description: "Alle Projekte, Filter und Einstiegspunkte fuer die Bearbeitung an einem Ort.",
|
||||||
|
totalCategories: "Kategorien",
|
||||||
|
totalProjects: "Projekte",
|
||||||
|
publishedProjects: "Veroeffentlicht",
|
||||||
|
newProject: "Neues Projekt",
|
||||||
|
newCategory: "Neue Kategorie",
|
||||||
|
category: "Kategorie",
|
||||||
|
all: "Alle",
|
||||||
|
status: "Status",
|
||||||
|
draft: "Entwurf",
|
||||||
|
published: "Veroeffentlicht",
|
||||||
|
filter: "Filter anwenden",
|
||||||
|
empty: "Noch keine Projekte vorhanden. Lege das erste Projekt an und beginne direkt mit Inhalt, Abschnitten und Dateien.",
|
||||||
|
openProject: "Projekt ansehen",
|
||||||
|
editProject: "Projekt bearbeiten",
|
||||||
|
review: "Bearbeitungsstand",
|
||||||
|
reviewDone: "Bereit",
|
||||||
|
reviewMissing: "Fehlt etwas",
|
||||||
|
untitled: "Unbenanntes Projekt",
|
||||||
|
noCategory: "Ohne Kategorie",
|
||||||
|
noPreview: "Kein Preview Link",
|
||||||
|
hasPreview: "Preview Link vorhanden",
|
||||||
|
hasCover: "Cover gesetzt",
|
||||||
|
missingCover: "Cover fehlt",
|
||||||
|
hasSections: "Abschnitte vorhanden",
|
||||||
|
noSections: "Keine Abschnitte",
|
||||||
|
hasAssets: "Dateien vorhanden",
|
||||||
|
noAssets: "Keine Dateien",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProjectCompletion(project: PortfolioProjectView) {
|
||||||
|
const completedChecks = [
|
||||||
|
Boolean(project.slug.trim()),
|
||||||
|
Boolean(project.coverImagePath),
|
||||||
|
project.sections.length > 0,
|
||||||
|
project.assets.length > 0,
|
||||||
|
Boolean(project.title.ar.trim() && project.title.en.trim() && project.title.de.trim()),
|
||||||
|
Boolean(project.summary.ar.trim() && project.summary.en.trim() && project.summary.de.trim()),
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
completedChecks,
|
||||||
|
totalChecks: 6,
|
||||||
|
ready: completedChecks === 6,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortfolioProjectsOverview({
|
||||||
|
categories,
|
||||||
|
projects,
|
||||||
|
selectedCategory,
|
||||||
|
selectedStatus,
|
||||||
|
}: PortfolioProjectsOverviewProps) {
|
||||||
|
const publishedProjects = projects.filter((project) => project.isPublished).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PortfolioSubnav active="projects" />
|
||||||
|
|
||||||
|
<MotionFade delay={0.05}>
|
||||||
|
<AppCard level={3}>
|
||||||
|
<CardContent className="flex flex-col gap-6 p-6 lg:flex-row lg:items-end lg:justify-between lg:p-8">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{copy.summary}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-semibold text-foreground">Projektverwaltung</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">{copy.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/root/portfolio/projects/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{copy.newProject}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/portfolio/categories">
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
{copy.newCategory}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: Layers3,
|
||||||
|
label: copy.totalCategories,
|
||||||
|
value: categories.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FolderKanban,
|
||||||
|
label: copy.totalProjects,
|
||||||
|
value: projects.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Boxes,
|
||||||
|
label: copy.publishedProjects,
|
||||||
|
value: publishedProjects,
|
||||||
|
},
|
||||||
|
].map((item, index) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionFade key={item.label} delay={0.08 + index * 0.04}>
|
||||||
|
<AppCard level={2}>
|
||||||
|
<CardContent className="flex items-center gap-4 p-6">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Icon className="h-5 w-5 text-brand-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{item.label}</p>
|
||||||
|
<p className="text-3xl font-semibold text-foreground">{item.value}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MotionFade delay={0.14}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="portfolio-filter-category" className="sr-only">
|
||||||
|
{copy.category}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Tags className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Select name="category" defaultValue={selectedCategory || "__all__"}>
|
||||||
|
<SelectTrigger id="portfolio-filter-category" className="pl-9">
|
||||||
|
<SelectValue placeholder={copy.category} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">{copy.all}</SelectItem>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<SelectItem key={category.id} value={category.id}>
|
||||||
|
{category.name.de}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="portfolio-filter-status" className="sr-only">
|
||||||
|
{copy.status}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Filter className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Select name="status" defaultValue={selectedStatus}>
|
||||||
|
<SelectTrigger id="portfolio-filter-status" className="pl-9">
|
||||||
|
<SelectValue placeholder={copy.status} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{copy.all}</SelectItem>
|
||||||
|
<SelectItem value="draft">{copy.draft}</SelectItem>
|
||||||
|
<SelectItem value="published">{copy.published}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button type="submit" variant="outline">
|
||||||
|
{copy.filter}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{projects.map((project, index) => {
|
||||||
|
const completion = getProjectCompletion(project);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionFade key={project.id} delay={0.18 + index * 0.03}>
|
||||||
|
<AppCard interactive>
|
||||||
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitle className="text-xl">
|
||||||
|
{getLocalizedValue(project.title, "de") || copy.untitled}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant={project.isPublished ? "success" : "warning"}>
|
||||||
|
{project.isPublished ? copy.published : copy.draft}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<span>{project.category.name.de || copy.noCategory}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{project.projectYear}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{project.slug}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={completion.ready ? "success" : "warning"}>
|
||||||
|
{copy.review}: {completion.completedChecks}/{completion.totalChecks}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{completion.ready ? copy.reviewDone : copy.reviewMissing}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||||
|
{getLocalizedValue(project.summary, "de") || "Noch keine Kurzbeschreibung hinterlegt."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={project.previewUrl ? "success" : "outline"}>
|
||||||
|
{project.previewUrl ? copy.hasPreview : copy.noPreview}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={project.coverImagePath ? "success" : "outline"}>
|
||||||
|
{project.coverImagePath ? copy.hasCover : copy.missingCover}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={project.sections.length > 0 ? "success" : "outline"}>
|
||||||
|
{project.sections.length > 0 ? copy.hasSections : copy.noSections}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={project.assets.length > 0 ? "success" : "outline"}>
|
||||||
|
{project.assets.length > 0 ? copy.hasAssets : copy.noAssets}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link
|
||||||
|
href={getLocalizedPath("de", `/portfolio/${project.slug}`)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{copy.openProject}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/root/portfolio/projects/${project.id}`}>{copy.editProject}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<MotionFade delay={0.18}>
|
||||||
|
<AppCard>
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">{copy.empty}</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/root/portfolio/projects/new">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{copy.newProject}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/root/portfolio/categories">
|
||||||
|
<Tags className="h-4 w-4" />
|
||||||
|
{copy.newCategory}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import { CardContent } from "@/components/ui/card";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PortfolioSubnavProps = {
|
type PortfolioSubnavProps = {
|
||||||
active: "overview" | "categories";
|
active: "projects" | "categories";
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
key: "overview",
|
key: "projects",
|
||||||
label: "Uebersicht",
|
label: "Projekte",
|
||||||
href: "/root/portfolio",
|
href: "/root/portfolio",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -30,10 +30,10 @@ export function PortfolioSubnav({ active }: PortfolioSubnavProps) {
|
|||||||
key={item.key}
|
key={item.key}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-pill border px-4 py-2 text-sm transition-colors",
|
"rounded-md border px-4 py-2 text-sm transition-colors",
|
||||||
active === item.key
|
active === item.key
|
||||||
? "border-border-strong bg-foreground text-background"
|
? "border-input bg-primary text-primary-foreground"
|
||||||
: "border-border bg-background text-foreground/75 hover:border-border-strong hover:text-foreground",
|
: "border-input bg-background text-foreground/75 hover:bg-accent hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SidebarMaintenanceControlProps = {
|
type SidebarMaintenanceControlProps = {
|
||||||
@@ -33,10 +34,10 @@ export function SidebarMaintenanceControl({
|
|||||||
<label
|
<label
|
||||||
htmlFor="sidebar-maintenance-enabled"
|
htmlFor="sidebar-maintenance-enabled"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center justify-between gap-3 rounded-nested border px-3 py-2 transition-colors",
|
"flex cursor-pointer items-center justify-between gap-3 rounded-md border px-3 py-2 transition-colors",
|
||||||
enabled
|
enabled
|
||||||
? "border-status-warning/40 bg-status-warning-soft/80"
|
? "border-status-warning/40 bg-status-warning-soft/80"
|
||||||
: "border-border bg-surface-1 hover:bg-surface-2",
|
: "border-input bg-card hover:bg-accent/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
@@ -63,11 +64,10 @@ export function SidebarMaintenanceControl({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<Checkbox
|
||||||
id="sidebar-maintenance-enabled"
|
id="sidebar-maintenance-enabled"
|
||||||
type="checkbox"
|
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onChange={(event) => setEnabled(event.target.checked)}
|
onCheckedChange={(checked) => setEnabled(checked === true)}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import { MediaKind } from "@prisma/client";
|
import { MediaKind } from "@prisma/client";
|
||||||
import { ImageIcon, Search, Type } from "lucide-react";
|
import { FileText, ImageIcon, Search, Type } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
import { MediaFieldPicker, type MediaFieldState } from "@/components/root/media-field-picker";
|
||||||
@@ -179,14 +179,16 @@ export function SiteSettingsForm({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 lg:grid-cols-3">
|
<CardContent className="grid gap-4 lg:grid-cols-3">
|
||||||
{localeFields.map((locale) => (
|
{localeFields.map((locale) => (
|
||||||
<div key={locale.key} className="space-y-4 rounded-surface border border-border bg-surface-1 p-4">
|
<div key={locale.key} className="space-y-4 rounded-lg border bg-card p-4 shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
<p className="text-sm font-medium text-foreground">{locale.label}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Globale Standardwerte fuer diese Sprache.</p>
|
<p className="mt-1 text-xs text-muted-foreground">Globale Standardwerte fuer diese Sprache.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`siteName${locale.suffix}`}>Site Name</Label>
|
<Label htmlFor={`siteName${locale.suffix}`} className="sr-only">Site Name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={`siteName${locale.suffix}`}
|
id={`siteName${locale.suffix}`}
|
||||||
name={`siteName${locale.suffix}`}
|
name={`siteName${locale.suffix}`}
|
||||||
@@ -203,11 +205,16 @@ export function SiteSettingsForm({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
placeholder="Site Name"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`titleTemplate${locale.suffix}`}>Title Template</Label>
|
<Label htmlFor={`titleTemplate${locale.suffix}`} className="sr-only">Title Template</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={`titleTemplate${locale.suffix}`}
|
id={`titleTemplate${locale.suffix}`}
|
||||||
name={`titleTemplate${locale.suffix}`}
|
name={`titleTemplate${locale.suffix}`}
|
||||||
@@ -224,12 +231,16 @@ export function SiteSettingsForm({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="{pageTitle} | {siteName}"
|
placeholder="Title Template"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`siteDescription${locale.suffix}`}>Site Description</Label>
|
<Label htmlFor={`siteDescription${locale.suffix}`} className="sr-only">Site Description</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<FileText className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={`siteDescription${locale.suffix}`}
|
id={`siteDescription${locale.suffix}`}
|
||||||
name={`siteDescription${locale.suffix}`}
|
name={`siteDescription${locale.suffix}`}
|
||||||
@@ -246,11 +257,16 @@ export function SiteSettingsForm({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
placeholder="Site Description"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`subhead${locale.suffix}`}>Subhead</Label>
|
<Label htmlFor={`subhead${locale.suffix}`} className="sr-only">Subhead</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Type className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id={`subhead${locale.suffix}`}
|
id={`subhead${locale.suffix}`}
|
||||||
name={`subhead${locale.suffix}`}
|
name={`subhead${locale.suffix}`}
|
||||||
@@ -267,9 +283,12 @@ export function SiteSettingsForm({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
placeholder="Subhead"
|
||||||
|
className="pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
@@ -344,7 +363,7 @@ export function SiteSettingsForm({
|
|||||||
: `${locale.sampleTitle} | ${localeSettings.siteName}`;
|
: `${locale.sampleTitle} | ${localeSettings.siteName}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={locale.key} className="rounded-nested border border-border bg-background p-3">
|
<div key={locale.key} className="rounded-md border bg-card p-3">
|
||||||
<p className="font-medium text-foreground">{locale.label}</p>
|
<p className="font-medium text-foreground">{locale.label}</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
Example page title: {locale.sampleTitle}
|
Example page title: {locale.sampleTitle}
|
||||||
@@ -365,12 +384,12 @@ export function SiteSettingsForm({
|
|||||||
<Search className="h-4 w-4 text-brand-primary" />
|
<Search className="h-4 w-4 text-brand-primary" />
|
||||||
Search Preview
|
Search Preview
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-nested border border-border bg-background p-4">
|
<div className="rounded-md border bg-card p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{faviconPreviewUrl ? (
|
{faviconPreviewUrl ? (
|
||||||
<img src={faviconPreviewUrl} alt="Favicon" className="h-5 w-5 rounded-sm" />
|
<img src={faviconPreviewUrl} alt="Favicon" className="h-5 w-5 rounded-sm" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-sm border border-border bg-surface-1">
|
<div className="flex h-5 w-5 items-center justify-center rounded-sm border bg-muted/40">
|
||||||
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -394,11 +413,11 @@ export function SiteSettingsForm({
|
|||||||
<ImageIcon className="h-4 w-4 text-brand-secondary" />
|
<ImageIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
Social Preview
|
Social Preview
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-nested border border-border bg-background">
|
<div className="overflow-hidden rounded-md border bg-card">
|
||||||
{defaultOgImagePreviewUrl ? (
|
{defaultOgImagePreviewUrl ? (
|
||||||
<img src={defaultOgImagePreviewUrl} alt="Default OG" className="h-32 w-full object-cover" />
|
<img src={defaultOgImagePreviewUrl} alt="Default OG" className="h-32 w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-32 items-center justify-center bg-surface-1 text-sm text-muted-foreground">
|
<div className="flex h-32 items-center justify-center bg-muted/40 text-sm text-muted-foreground">
|
||||||
No OG image selected
|
No OG image selected
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const appCardVariants = cva(
|
const appCardVariants = cva(
|
||||||
"rounded-surface border transition-all duration-200",
|
"rounded-lg transition-colors",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
level: {
|
level: {
|
||||||
1: "border-border bg-surface-1 text-foreground shadow-card",
|
1: "border bg-card text-card-foreground shadow-sm",
|
||||||
2: "border-border bg-surface-2 text-foreground shadow-sm",
|
2: "border bg-card text-card-foreground shadow-sm",
|
||||||
3: "border-border/90 bg-surface-3 text-foreground shadow-panel",
|
3: "border bg-card text-card-foreground shadow-md",
|
||||||
inverse: "border-transparent bg-surface-inverse text-surface-inverse-foreground shadow-lg",
|
inverse: "border-transparent bg-foreground text-background shadow-sm",
|
||||||
},
|
},
|
||||||
padding: {
|
padding: {
|
||||||
none: "",
|
none: "",
|
||||||
@@ -21,7 +21,7 @@ const appCardVariants = cva(
|
|||||||
lg: "p-8",
|
lg: "p-8",
|
||||||
},
|
},
|
||||||
interactive: {
|
interactive: {
|
||||||
true: "hover:-translate-y-0.5 hover:border-border-strong hover:shadow-md",
|
true: "hover:bg-accent/30 hover:text-accent-foreground",
|
||||||
false: "",
|
false: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const badgeVariants = cva(
|
|||||||
variant: {
|
variant: {
|
||||||
default: "border-transparent bg-primary text-primary-foreground",
|
default: "border-transparent bg-primary text-primary-foreground",
|
||||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||||
outline: "border-border bg-surface-1 text-foreground",
|
outline: "border-input bg-background text-foreground",
|
||||||
success: "border-transparent bg-status-success-soft text-status-success",
|
success: "border-transparent bg-status-success-soft text-status-success",
|
||||||
warning: "border-transparent bg-status-warning-soft text-status-warning",
|
warning: "border-transparent bg-status-warning-soft text-status-warning",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-nested text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground shadow-xs hover:brightness-95",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
outline: "border border-border bg-surface-1 text-foreground shadow-xs hover:border-border-strong hover:bg-surface-2",
|
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
link: "rounded-none text-primary underline-offset-4 hover:underline",
|
link: "rounded-none text-primary underline-offset-4 hover:underline",
|
||||||
destructive: "bg-destructive text-destructive-foreground shadow-xs hover:brightness-95",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-10 px-4 py-2",
|
default: "h-10 px-4 py-2",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-surface border border-border bg-surface-1 text-card-foreground shadow-card",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
CheckboxProps
|
||||||
|
>(({ className, name, checked, defaultChecked, onCheckedChange, ...props }, ref) => {
|
||||||
|
const [internalChecked, setInternalChecked] = React.useState(defaultChecked === true);
|
||||||
|
const isControlled = checked !== undefined;
|
||||||
|
const currentChecked = isControlled ? checked === true : internalChecked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{name ? <input type="hidden" name={name} value={currentChecked ? "on" : "false"} /> : null}
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
checked={checked}
|
||||||
|
defaultChecked={defaultChecked}
|
||||||
|
onCheckedChange={(nextChecked) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalChecked(nextChecked === true);
|
||||||
|
}
|
||||||
|
onCheckedChange?.(nextChecked);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-nested border border-input bg-surface-1 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type SelectProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root> & {
|
||||||
|
name?: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
name,
|
||||||
|
required,
|
||||||
|
value,
|
||||||
|
defaultValue,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectProps) {
|
||||||
|
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
|
||||||
|
const isControlled = value !== undefined;
|
||||||
|
const currentValue = (isControlled ? value : internalValue) as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{name ? <input type="hidden" name={name} value={currentValue} required={required} /> : null}
|
||||||
|
<SelectPrimitive.Root
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalValue(nextValue);
|
||||||
|
}
|
||||||
|
onValueChange?.(nextValue);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Root>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4 rotate-180" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
@@ -44,7 +44,7 @@ function TabsList({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-auto flex-wrap items-center gap-2 rounded-surface border border-border bg-surface-2 p-1.5",
|
"inline-flex h-auto flex-wrap items-center gap-2 rounded-lg border bg-muted/40 p-1.5",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -69,8 +69,8 @@ function TabsTrigger({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center rounded-nested px-3 py-2 text-sm font-medium text-muted-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
isActive && "bg-surface-1 text-foreground shadow-xs",
|
isActive && "bg-background text-foreground shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"tex
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<textarea
|
<textarea
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-[112px] w-full rounded-nested border border-input bg-surface-1 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
|||||||
<div className="flex flex-col gap-section">
|
<div className="flex flex-col gap-section">
|
||||||
<AppCard level={3}>
|
<AppCard level={3}>
|
||||||
<CardContent className="flex flex-col gap-4 p-6 lg:p-10">
|
<CardContent className="flex flex-col gap-4 p-6 lg:p-10">
|
||||||
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||||
<Layers3 className="h-3.5 w-3.5 text-brand-secondary" />
|
<Layers3 className="h-3.5 w-3.5 text-brand-secondary" />
|
||||||
{copy.title}
|
{copy.title}
|
||||||
</div>
|
</div>
|
||||||
@@ -292,10 +292,10 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
<AppCard level="inverse">
|
<AppCard level="inverse">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<p className="text-sm font-medium text-surface-inverse-foreground/80">
|
<p className="text-sm font-medium text-background/80">
|
||||||
{copy.inverseOuterCard}
|
{copy.inverseOuterCard}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-surface-inverse-foreground/88">
|
<p className="mt-2 text-sm text-background/88">
|
||||||
{copy.inverseOuterCardText}
|
{copy.inverseOuterCardText}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -452,8 +452,8 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
<AppCard level="inverse">
|
<AppCard level="inverse">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<p className="font-medium text-surface-inverse-foreground">{copy.inverse}</p>
|
<p className="font-medium text-background">{copy.inverse}</p>
|
||||||
<p className="mt-2 text-sm text-surface-inverse-foreground/88">{copy.inverseText}</p>
|
<p className="mt-2 text-sm text-background/88">{copy.inverseText}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,7 +492,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) {
|
|||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<AppCard level={3}>
|
<AppCard level={3}>
|
||||||
<CardContent className="space-y-3 p-6">
|
<CardContent className="space-y-3 p-6">
|
||||||
<div className="inline-flex w-fit items-center gap-2 rounded-pill border border-border bg-surface-1 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-input bg-background px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||||
<Type className="h-3.5 w-3.5 text-brand-primary" />
|
<Type className="h-3.5 w-3.5 text-brand-primary" />
|
||||||
{copy.pageHeader}
|
{copy.pageHeader}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,10 +72,10 @@ export function getRootNavigation(
|
|||||||
expanded: active === "portfolio",
|
expanded: active === "portfolio",
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Projects",
|
||||||
href: "/root/portfolio",
|
href: "/root/portfolio",
|
||||||
icon: LayoutDashboard,
|
icon: FolderKanban,
|
||||||
active: portfolioChild === "overview",
|
active: portfolioChild === "overview" || portfolioChild === "projects",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Add Project",
|
label: "Add Project",
|
||||||
|
|||||||
Generated
+137
@@ -10,8 +10,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -1635,6 +1637,12 @@
|
|||||||
"react-dom": "^18.0.0 || ^19.0.0"
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
@@ -1664,6 +1672,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -2131,6 +2169,67 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.7",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.6.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
@@ -2234,6 +2333,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-rect": {
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
@@ -2270,6 +2384,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/rect": {
|
"node_modules/@radix-ui/rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.4.2",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.4.2",
|
"@prisma/client": "^7.4.2",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user