diff --git a/.env.example b/.env.example index e96ce14..cce5707 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,12 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 BETTER_AUTH_SECRET=album-development-secret-change-me BETTER_AUTH_URL=http://localhost:3000 +# Optional Authentik OpenID Connect provider. Use the issuer URL shown by +# Authentik, for example: https://auth.example.com/application/o/vellum/ +AUTHENTIK_ISSUER= +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= + EMAIL_PROVIDER=mailpit EMAIL_FROM=Album SMTP_HOST=127.0.0.1 diff --git a/AGENTS.md b/AGENTS.md index 7a1dd13..5fb9c0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Vellum repository guidance +# Manyangles repository guidance - Use Bun exclusively for dependency installation and project scripts. Do not introduce npm, pnpm, or Yarn lockfiles or commands. diff --git a/README.md b/README.md index 70f4305..2cd8944 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Vellum +# Manyangles Event photo collection for a wedding day — and for any gathering that needs the same guest-upload, host-moderate, public-gallery loop. @@ -46,3 +46,32 @@ Example accounts (password `host`, admin password `admin`): - `admin@example.com` — platform super-admin - `host@example.com` / `partner@example.com` — event owners - `manager@example.com` — event manager + +## Authentik sign-in + +Manyangles can use an Authentik OAuth2/OpenID Connect provider alongside email and +password authentication. In Authentik, create a confidential OAuth2/OpenID +provider and application with the `openid`, `profile`, and `email` scopes. + +Add a strict redirect URI for each Manyangles deployment: + +```text +https://photos.example.com/api/auth/oauth2/callback/authentik +``` + +For local development, use +`http://localhost:3000/api/auth/oauth2/callback/authentik`. + +Set all three variables to enable the Authentik button: + +```dotenv +AUTHENTIK_ISSUER=https://auth.example.com/application/o/manyangles/ +AUTHENTIK_CLIENT_ID=your-client-id +AUTHENTIK_CLIENT_SECRET=your-client-secret +``` + +`AUTHENTIK_ISSUER` is the application issuer URL, not the Authentik root URL. +Manyangles discovers the authorization, token, user-info, and signing-key endpoints +from `/.well-known/openid-configuration`. If none of these variables are +set, Authentik support stays disabled. A partial configuration fails at startup +instead of silently hiding a broken provider. diff --git a/apps/web/src/app/apple-icon.png b/apps/web/src/app/apple-icon.png index 82c0c7d..b695aff 100644 Binary files a/apps/web/src/app/apple-icon.png and b/apps/web/src/app/apple-icon.png differ diff --git a/apps/web/src/app/dashboard/create-event-dialog.tsx b/apps/web/src/app/dashboard/create-event-dialog.tsx index 908bca1..29b5179 100644 --- a/apps/web/src/app/dashboard/create-event-dialog.tsx +++ b/apps/web/src/app/dashboard/create-event-dialog.tsx @@ -71,7 +71,7 @@ export function CreateEventDialog() { required value={title} onChange={(event) => setTitle(event.target.value)} - placeholder="Maya and Jonah" + placeholder="Summer block party" /> @@ -88,8 +88,15 @@ export function CreateEventDialog() { setInviteCode(event.target.value)} - placeholder="VELLUM-…" + onChange={(event) => + setInviteCode(event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "")) + } + placeholder="AB12" + minLength={4} + maxLength={4} + pattern="[A-Za-z0-9]{4}" + autoCapitalize="characters" + autoCorrect="off" /> diff --git a/apps/web/src/app/e/[slug]/guest-upload.tsx b/apps/web/src/app/e/[slug]/guest-upload.tsx index c58a169..8136b3c 100644 --- a/apps/web/src/app/e/[slug]/guest-upload.tsx +++ b/apps/web/src/app/e/[slug]/guest-upload.tsx @@ -251,7 +251,7 @@ export function GuestUpload({ autoComplete="name" value={name} onChange={(event) => setName(event.target.value)} - placeholder="Maya…" + placeholder="Your name" maxLength={80} className="tap-target" /> diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index d98b61b..b383716 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -19,9 +19,9 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --font-sans: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif; - --font-heading: var(--font-source-sans), ui-sans-serif, system-ui, sans-serif; - --font-display: var(--font-cormorant), ui-serif, Georgia, serif; + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-heading: var(--font-playfair), ui-serif, Georgia, serif; + --font-display: var(--font-playfair), ui-serif, Georgia, serif; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -50,73 +50,73 @@ :root { color-scheme: light; - --background: oklch(0.965 0.016 82); - --foreground: oklch(0.26 0.03 48); - --card: oklch(0.99 0.008 85); - --card-foreground: oklch(0.26 0.03 48); - --popover: oklch(0.995 0.006 85); - --popover-foreground: oklch(0.26 0.03 48); - --primary: oklch(0.46 0.09 38); - --primary-foreground: oklch(0.99 0.01 85); - --secondary: oklch(0.94 0.02 80); - --secondary-foreground: oklch(0.3 0.04 48); - --muted: oklch(0.94 0.018 82); - --muted-foreground: oklch(0.48 0.03 55); - --accent: oklch(0.93 0.03 70); - --accent-foreground: oklch(0.3 0.04 48); - --destructive: oklch(0.55 0.18 28); - --border: oklch(0.88 0.025 78); - --input: oklch(0.86 0.025 78); - --ring: oklch(0.46 0.09 38); - --chart-1: oklch(0.72 0.08 55); - --chart-2: oklch(0.55 0.08 40); - --chart-3: oklch(0.45 0.06 70); - --chart-4: oklch(0.38 0.04 50); - --chart-5: oklch(0.3 0.03 45); - --radius: 0.85rem; - --sidebar: oklch(0.97 0.012 82); - --sidebar-foreground: oklch(0.26 0.03 48); - --sidebar-primary: oklch(0.46 0.09 38); - --sidebar-primary-foreground: oklch(0.99 0.01 85); - --sidebar-accent: oklch(0.93 0.03 70); - --sidebar-accent-foreground: oklch(0.3 0.04 48); - --sidebar-border: oklch(0.88 0.025 78); - --sidebar-ring: oklch(0.46 0.09 38); + --background: #f4f1ea; + --foreground: #171716; + --card: #fffefa; + --card-foreground: #171716; + --popover: #fffefa; + --popover-foreground: #171716; + --primary: #3156c8; + --primary-foreground: #ffffff; + --secondary: #e7e4dc; + --secondary-foreground: #252526; + --muted: #e9e5dc; + --muted-foreground: #5e5a52; + --accent: #e4e9f8; + --accent-foreground: #203b90; + --destructive: oklch(0.58 0.22 27); + --border: #c9c3b7; + --input: #c7c1b6; + --ring: #3156c8; + --chart-1: #3156c8; + --chart-2: oklch(0.64 0.12 210); + --chart-3: oklch(0.7 0.13 145); + --chart-4: oklch(0.72 0.13 75); + --chart-5: oklch(0.56 0.12 300); + --radius: 0.75rem; + --sidebar: #fffefa; + --sidebar-foreground: #171716; + --sidebar-primary: #3156c8; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e4e9f8; + --sidebar-accent-foreground: #203b90; + --sidebar-border: #c9c3b7; + --sidebar-ring: #3156c8; } .dark { color-scheme: dark; - --background: oklch(0.2 0.02 50); - --foreground: oklch(0.96 0.015 85); - --card: oklch(0.25 0.022 52); - --card-foreground: oklch(0.96 0.015 85); - --popover: oklch(0.25 0.022 52); - --popover-foreground: oklch(0.96 0.015 85); - --primary: oklch(0.78 0.07 65); - --primary-foreground: oklch(0.22 0.03 50); - --secondary: oklch(0.3 0.025 55); - --secondary-foreground: oklch(0.96 0.015 85); - --muted: oklch(0.3 0.022 52); - --muted-foreground: oklch(0.76 0.03 75); - --accent: oklch(0.32 0.03 58); - --accent-foreground: oklch(0.96 0.015 85); - --destructive: oklch(0.7 0.14 25); - --border: oklch(0.96 0.02 85 / 12%); - --input: oklch(0.96 0.02 85 / 14%); - --ring: oklch(0.78 0.07 65); - --chart-1: oklch(0.78 0.07 65); - --chart-2: oklch(0.65 0.06 45); - --chart-3: oklch(0.55 0.05 75); - --chart-4: oklch(0.45 0.04 55); - --chart-5: oklch(0.38 0.03 50); - --sidebar: oklch(0.23 0.02 50); - --sidebar-foreground: oklch(0.96 0.015 85); - --sidebar-primary: oklch(0.78 0.07 65); - --sidebar-primary-foreground: oklch(0.22 0.03 50); - --sidebar-accent: oklch(0.3 0.025 55); - --sidebar-accent-foreground: oklch(0.96 0.015 85); - --sidebar-border: oklch(0.96 0.02 85 / 12%); - --sidebar-ring: oklch(0.78 0.07 65); + --background: #0d1018; + --foreground: #f4f1ea; + --card: #171b26; + --card-foreground: #f4f1ea; + --popover: #1b202d; + --popover-foreground: #f4f1ea; + --primary: #7f9cff; + --primary-foreground: #0c1634; + --secondary: #262b37; + --secondary-foreground: #f4f1ea; + --muted: #242936; + --muted-foreground: #bbb8b1; + --accent: #202b50; + --accent-foreground: #b9c7ff; + --destructive: oklch(0.7 0.18 25); + --border: #3b4150; + --input: #4a5161; + --ring: #7f9cff; + --chart-1: #7f9cff; + --chart-2: oklch(0.7 0.12 210); + --chart-3: oklch(0.72 0.13 145); + --chart-4: oklch(0.75 0.13 75); + --chart-5: oklch(0.68 0.12 300); + --sidebar: #171b26; + --sidebar-foreground: #f4f1ea; + --sidebar-primary: #7f9cff; + --sidebar-primary-foreground: #0c1634; + --sidebar-accent: #202b50; + --sidebar-accent-foreground: #b9c7ff; + --sidebar-border: #3b4150; + --sidebar-ring: #7f9cff; } @keyframes reveal-up { @@ -166,6 +166,70 @@ .tap-target { min-height: 2.75rem; } + + .album-preview { + border-color: color-mix(in oklab, var(--primary) 50%, var(--border)); + box-shadow: 0 28px 80px color-mix(in oklab, var(--foreground) 17%, transparent); + } + + .album-preview > [data-slot="card-header"] { + color: var(--primary-foreground); + background: var(--primary); + border-color: color-mix(in oklab, var(--primary-foreground) 20%, transparent); + } + + .album-preview [data-slot="card-description"] { + color: color-mix(in oklab, var(--primary-foreground) 72%, transparent); + } + + .album-preview [data-slot="badge"] { + color: var(--primary-foreground); + background: color-mix(in oklab, var(--primary-foreground) 14%, transparent); + border-color: color-mix(in oklab, var(--primary-foreground) 24%, transparent); + } + + .feature-panel { + position: relative; + } + + .feature-panel::before { + position: absolute; + inset: 0 0 auto; + height: 3px; + content: ""; + background: var(--primary); + } + + .memory-photo { + overflow: hidden; + border-radius: var(--radius-lg); + background-color: var(--muted); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 8%, transparent); + } + + .memory-photo-one { + background: radial-gradient(circle at 50% 42%, oklch(0.91 0.04 62) 0 11%, transparent 12%), radial-gradient(ellipse at 48% 72%, oklch(0.48 0.08 35) 0 24%, transparent 25%), linear-gradient(145deg, oklch(0.82 0.1 76), oklch(0.58 0.09 38)); + } + + .memory-photo-two { + background: radial-gradient(circle at 30% 52%, oklch(0.95 0.03 75) 0 10%, transparent 11%), radial-gradient(circle at 67% 48%, oklch(0.93 0.035 62) 0 12%, transparent 13%), linear-gradient(150deg, oklch(0.57 0.1 230), oklch(0.83 0.08 205)); + } + + .memory-photo-three { + background: radial-gradient(circle at 50% 38%, oklch(0.9 0.045 55) 0 13%, transparent 14%), linear-gradient(25deg, transparent 42%, oklch(0.32 0.05 45) 43% 60%, transparent 61%), linear-gradient(155deg, oklch(0.8 0.12 135), oklch(0.48 0.09 150)); + } + + .memory-photo-four { + background: radial-gradient(circle at 50% 52%, oklch(0.95 0.025 62) 0 11%, transparent 12%), radial-gradient(ellipse at 52% 75%, oklch(0.36 0.055 265) 0 27%, transparent 28%), linear-gradient(165deg, oklch(0.73 0.11 35), oklch(0.37 0.07 300)); + } + + .memory-photo-five { + background: radial-gradient(circle at 70% 34%, oklch(0.91 0.09 85) 0 8%, transparent 9%), linear-gradient(20deg, oklch(0.42 0.08 155) 0 42%, transparent 43%), linear-gradient(155deg, oklch(0.76 0.09 215), oklch(0.93 0.06 80)); + } + + .memory-photo-six { + background: radial-gradient(circle at 35% 45%, oklch(0.94 0.035 65) 0 11%, transparent 12%), radial-gradient(circle at 65% 47%, oklch(0.92 0.04 58) 0 10%, transparent 11%), linear-gradient(145deg, oklch(0.77 0.11 28), oklch(0.47 0.08 20)); + } } @media (prefers-reduced-motion: reduce) { @@ -191,26 +255,21 @@ body { @apply bg-background font-sans text-foreground antialiased; background-image: - radial-gradient(1200px 520px at 8% -12%, oklch(0.92 0.045 70 / 0.55), transparent 60%), - radial-gradient(900px 420px at 110% 8%, oklch(0.93 0.035 40 / 0.4), transparent 55%); + radial-gradient(circle at 82% 7%, color-mix(in oklab, var(--primary) 15%, transparent), transparent 30rem), + radial-gradient(circle at 4% 88%, color-mix(in oklab, var(--primary) 5%, transparent), transparent 24rem); background-attachment: fixed; touch-action: manipulation; } - .dark body { - background-image: - radial-gradient(1000px 480px at 0% -10%, oklch(0.32 0.04 55 / 0.55), transparent 60%), - radial-gradient(800px 380px at 100% 0%, oklch(0.3 0.04 70 / 0.35), transparent 55%); - } - .font-display { - font-family: var(--font-cormorant), ui-serif, Georgia, serif; + font-family: var(--font-playfair), ui-serif, Georgia, serif; text-wrap: balance; } h1, h2, h3 { + font-family: var(--font-playfair), ui-serif, Georgia, serif; text-wrap: balance; } diff --git a/apps/web/src/app/icon.svg b/apps/web/src/app/icon.svg index fc6b112..6111a74 100644 --- a/apps/web/src/app/icon.svg +++ b/apps/web/src/app/icon.svg @@ -1,8 +1,6 @@ - - + + + + diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 908ad86..708c174 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { Cormorant_Garamond, Source_Sans_3 } from "next/font/google"; +import { Inter, Playfair_Display } from "next/font/google"; import { TRPCReactProvider } from "@/trpc/react"; import { ThemeProvider } from "@/components/theme-provider"; import { SiteHeader } from "@/components/site-header"; @@ -7,17 +7,17 @@ import { Toaster } from "@/components/ui/sonner"; import { BRAND_NAME, BRAND_TITLE } from "@/lib/brand"; import "./globals.css"; -const sourceSans = Source_Sans_3({ - variable: "--font-source-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], display: "swap", }); -const cormorant = Cormorant_Garamond({ - variable: "--font-cormorant", +const playfair = Playfair_Display({ + variable: "--font-playfair", subsets: ["latin"], display: "swap", - weight: ["500", "600", "700"], + weight: ["600", "700", "800"], }); export const metadata: Metadata = { @@ -32,8 +32,8 @@ export const metadata: Metadata = { export const viewport: Viewport = { themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#f4eee4" }, - { media: "(prefers-color-scheme: dark)", color: "#2a231c" }, + { media: "(prefers-color-scheme: light)", color: "#f4f1ea" }, + { media: "(prefers-color-scheme: dark)", color: "#0d1018" }, ], viewportFit: "cover", }; @@ -44,7 +44,7 @@ export default function RootLayout({ return ( diff --git a/apps/web/src/app/opengraph-image.tsx b/apps/web/src/app/opengraph-image.tsx index 374e604..f603b3f 100644 --- a/apps/web/src/app/opengraph-image.tsx +++ b/apps/web/src/app/opengraph-image.tsx @@ -9,7 +9,7 @@ export default function OpenGraphImage() { (
- + + +
{BRAND_NAME} @@ -46,9 +45,10 @@ export default function OpenGraphImage() { style={{ marginTop: 18, fontSize: 28, - color: "#8b5a4a", - letterSpacing: 6, - textTransform: "uppercase", + maxWidth: 760, + color: "#71717a", + lineHeight: 1.25, + letterSpacing: -1, }} > {BRAND_TAGLINE} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 98bedc9..4da12f7 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,7 +1,15 @@ import Link from "next/link"; -import { CameraIcon } from "lucide-react"; +import { + ArrowRightIcon, + CheckCircle2Icon, + DownloadIcon, + ImagesIcon, + ShieldCheckIcon, + UploadIcon, +} from "lucide-react"; import { createServerCaller } from "@/trpc/server"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { BrandMark } from "@/components/brand-mark"; import { formatEventDate } from "@/lib/utils"; import { @@ -12,73 +20,170 @@ import { CardTitle, } from "@/components/ui/card"; +const features = [ + { + number: "01", + icon: UploadIcon, + title: "One link for everyone", + body: "Guests upload from any phone. No account, app, or shared camera roll required.", + }, + { + number: "02", + icon: ShieldCheckIcon, + title: "You decide what’s public", + body: "Every photo lands in a private review queue before it reaches the shared gallery.", + }, + { + number: "03", + icon: DownloadIcon, + title: "Originals stay original", + body: "Keep the full-quality files while Manyangles makes the gallery fast for everyone else.", + }, +] as const; + +function AlbumPreview() { + return ( +
+ + +
+ + +
+ Saturday album + Brooklyn · 84 photos +
+
+ + +
+ +