From 07337de7d551495cb188c0b06914ffc939dd8836 Mon Sep 17 00:00:00 2001 From: AntoninMignotPilon Date: Sun, 21 Jun 2026 17:48:37 +0200 Subject: [PATCH 01/22] WIP --- apps/backend/src/main/resources/instance.json | 2 +- apps/frontend/app/actions/theme.ts | 23 + apps/frontend/app/admin/page.tsx | 270 ++++-- apps/frontend/app/bookmarks/page.tsx | 74 +- apps/frontend/app/courses/[slug]/page.tsx | 84 +- .../frontend/app/courses/[slug]/read/page.tsx | 23 +- apps/frontend/app/courses/page.tsx | 7 - apps/frontend/app/dashboard/page.tsx | 19 + apps/frontend/app/globals.css | 299 ++++++- apps/frontend/app/layout.tsx | 44 +- apps/frontend/app/learn/page.tsx | 214 +++++ apps/frontend/app/login/login-shell.tsx | 273 +++--- apps/frontend/app/page.tsx | 399 +++------ apps/frontend/app/studio/page.tsx | 242 ++++++ .../components/admin/course-editor.tsx | 811 +++++++++++------- .../frontend/components/admin/course-form.tsx | 5 + apps/frontend/components/block-kinds/code.tsx | 2 +- .../components/block-kinds/sandbox.tsx | 2 +- .../components/course/catalog-course-card.tsx | 99 +++ .../components/course/catalog-grid.tsx | 86 +- .../components/course/course-save-button.tsx | 63 ++ .../frontend/components/home/aurora-layer.tsx | 90 ++ apps/frontend/components/theme-provider.tsx | 94 ++ apps/frontend/components/theme-script.tsx | 15 + apps/frontend/components/theme-toggle.tsx | 69 ++ apps/frontend/components/top-nav.tsx | 54 +- apps/frontend/components/ui/magnetic.tsx | 67 ++ .../components/ui/slide-to-confirm.tsx | 255 ++++++ apps/frontend/lib/instance.ts | 2 +- apps/frontend/lib/theme.ts | 27 + apps/frontend/messages/en.json | 164 ++-- apps/frontend/messages/fr.json | 167 ++-- apps/frontend/proxy.ts | 2 +- 33 files changed, 2926 insertions(+), 1121 deletions(-) create mode 100644 apps/frontend/app/actions/theme.ts create mode 100644 apps/frontend/app/dashboard/page.tsx create mode 100644 apps/frontend/app/learn/page.tsx create mode 100644 apps/frontend/app/studio/page.tsx create mode 100644 apps/frontend/components/course/catalog-course-card.tsx create mode 100644 apps/frontend/components/course/course-save-button.tsx create mode 100644 apps/frontend/components/home/aurora-layer.tsx create mode 100644 apps/frontend/components/theme-provider.tsx create mode 100644 apps/frontend/components/theme-script.tsx create mode 100644 apps/frontend/components/theme-toggle.tsx create mode 100644 apps/frontend/components/ui/magnetic.tsx create mode 100644 apps/frontend/components/ui/slide-to-confirm.tsx create mode 100644 apps/frontend/lib/theme.ts diff --git a/apps/backend/src/main/resources/instance.json b/apps/backend/src/main/resources/instance.json index 74b3b06..cf9e8a4 100644 --- a/apps/backend/src/main/resources/instance.json +++ b/apps/backend/src/main/resources/instance.json @@ -2,7 +2,7 @@ "name": "Codestar", "tagline": "Open-source e-learning platform", "logo": { "kind": "preset", "value": "star" }, - "accent": "#7AA9FF", + "accent": "#EAB12E", "heroTitle": null, "heroSubtitle": null, "heroCta": null, diff --git a/apps/frontend/app/actions/theme.ts b/apps/frontend/app/actions/theme.ts new file mode 100644 index 0000000..0e399d3 --- /dev/null +++ b/apps/frontend/app/actions/theme.ts @@ -0,0 +1,23 @@ +"use server"; + +/** + * Persist the chosen theme via `THEME_COOKIE` (non-httpOnly so the inline + * can read it). Mirrors `actions/locale.ts`. + */ + +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; + +import { THEME_COOKIE, isTheme } from "@/lib/theme"; + +export async function setThemeAction(value: string) { + if (!isTheme(value)) return; + const cookieStore = await cookies(); + cookieStore.set(THEME_COOKIE, value, { + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1 year + sameSite: "lax", + httpOnly: false, + }); + revalidatePath("/", "layout"); +} diff --git a/apps/frontend/app/admin/page.tsx b/apps/frontend/app/admin/page.tsx index 5f398d0..f8c5df3 100644 --- a/apps/frontend/app/admin/page.tsx +++ b/apps/frontend/app/admin/page.tsx @@ -3,11 +3,10 @@ import Link from "next/link"; import { getLocale, getTranslations } from "next-intl/server"; import { getInstanceBranding } from "@/app/actions/instance"; -import { - getAllCourses, - getMyAuthoredCourses, -} from "@/app/actions/courses"; -import { getMyGroups, getAllGroups } from "@/app/actions/groups"; +import { getAllCourses } from "@/app/actions/courses"; +import { getAllGroups } from "@/app/actions/groups"; +import { getSettings } from "@/app/actions/settings"; +import { getAllUsers } from "@/app/actions/users"; import { AdminShell } from "@/components/admin/admin-shell"; import { GroupCardActions } from "@/components/admin/group-card-actions"; import { requireRole } from "@/components/admin/role-guard"; @@ -15,10 +14,8 @@ import { CourseMeta } from "@/components/course/course-meta"; import { PageHeader } from "@/components/course/page-header"; import { StatCard } from "@/components/course/stat-card"; import { GlassButton } from "@/components/ui/glass-button"; -import { - GlassCard, - GlassCardContent, -} from "@/components/ui/glass-card"; +import { GlassCard, GlassCardContent } from "@/components/ui/glass-card"; +import { GlassChip } from "@/components/ui/glass-chip"; import { BookIcon, ChartIcon, @@ -26,40 +23,59 @@ import { SparklesIcon, UsersIcon, } from "@/components/ui/icons"; -import { isAdmin } from "@/lib/roles"; -import type { CourseSummary } from "@/lib/types"; +import { isSuperAdmin } from "@/lib/roles"; +import type { CourseSummary, GroupResponse, Role } from "@/lib/types"; export const metadata: Metadata = { title: "Admin" }; +const ROLE_ORDER: Role[] = ["STUDENT", "TEACHER", "ADMIN", "SUPER_ADMIN"]; + +/** Module-scope (not a component) so the `now` read stays out of render. */ +function countActiveGroups(groups: GroupResponse[]): number { + const now = Date.now(); + return groups.filter( + (g) => !g.endsAt || new Date(g.endsAt).getTime() >= now + ).length; +} + export default async function AdminDashboardPage() { - const me = await requireRole("TEACHER"); - const admin = isAdmin(me.role); + const me = await requireRole("ADMIN"); + const superAdmin = isSuperAdmin(me.role); const locale = (await getLocale()) as "fr" | "en"; - const [t, branding] = await Promise.all([ - getTranslations("admin"), - getInstanceBranding(), - ]); - const [courses, groups] = await Promise.all([ - admin ? getAllCourses() : getMyAuthoredCourses(), - admin ? getAllGroups() : getMyGroups(), - ]); + const [t, tRoles, branding, courses, groups, users, settings] = + await Promise.all([ + getTranslations("admin"), + getTranslations("roles"), + getInstanceBranding(), + getAllCourses(), + getAllGroups(), + getAllUsers(), + getSettings(), + ]); const published = courses.filter((c) => c.status === "PUBLISHED").length; const drafts = courses.filter((c) => c.status === "DRAFT").length; - const sorted = [...courses].sort( + const archived = courses.filter((c) => c.status === "ARCHIVED").length; + + const activeGroups = countActiveGroups(groups); + + const disabledUsers = users.filter((u) => u.disabledAt).length; + const usersByRole = ROLE_ORDER.map((r) => ({ + role: r, + count: users.filter((u) => u.role === r).length, + })); + const maxRoleCount = Math.max(1, ...usersByRole.map((x) => x.count)); + + const recent = [...courses].sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); return ( @@ -75,27 +91,22 @@ export default async function AdminDashboardPage() { - {t("recentTitle")} + {t("usersLink")} + + + {t("settingsLink")} - {admin && ( - - {t("usersLink")} - - )} - {admin && ( - - {t("settingsLink")} - - )} } className="mb-10" /> + {/* KPIs */}
} /> } /> } + label={t("kpi.users")} + value={users.length} + hint={t("kpi.disabled", { count: disabledUsers })} + icon={} /> } />
+ {/* Catalog health + users by role */} +
+ + +

+ {t("kpi.catalogHealth")} +

+
+ + + +
+
+
+ + + +

+ {t("kpi.usersByRole")} +

+
    + {usersByRole.map(({ role, count }) => ( +
  • + + {tRoles(role)} + + + + + + {count} + +
  • + ))} +
+
+
+
+ + {/* Super-admin instance block */} + {superAdmin && ( +
+ + +
+ + + +
+
+ {t("superAdmin.kicker")} +
+

+ {t("superAdmin.title", { name: branding.name })} +

+
+ + + {branding.accent} + + · + + {branding.locale.toUpperCase()} + + {settings && ( + <> + · + + {t("superAdmin.maxBlocks", { + n: settings.maxBlocksPerPage, + })} + + + )} +
+
+
+
+ + {t("settingsLink")} + + + {t("superAdmin.manageRoles")} + +
+
+
+
+ )} + + {/* Recent courses + groups */}
@@ -126,13 +256,16 @@ export default async function AdminDashboardPage() {
    - {sorted.slice(0, 5).map((c) => ( + {recent.slice(0, 5).map((c) => (
  • ))} - {sorted.length === 0 && ( - + {recent.length === 0 && ( + {t("table.empty")} )} @@ -144,7 +277,10 @@ export default async function AdminDashboardPage() { {t("groupsTitle")} {groups.length === 0 ? ( - + {t("noGroups")} ) : ( @@ -155,7 +291,9 @@ export default async function AdminDashboardPage() {
    {g.name}
    -
    {g.slug}
    +
    + {g.slug} +
    {t("groupsManage")} → - · + + · + 0 ? Math.round((value / total) * 100) : 0; + return ( +
    + + {label} + + + + + + {value} + +
    + ); +} + function RecentCourse({ course, locale, diff --git a/apps/frontend/app/bookmarks/page.tsx b/apps/frontend/app/bookmarks/page.tsx index f13ec81..87780a4 100644 --- a/apps/frontend/app/bookmarks/page.tsx +++ b/apps/frontend/app/bookmarks/page.tsx @@ -4,14 +4,13 @@ import { getTranslations } from "next-intl/server"; import { getMyBookmarks } from "@/app/actions/bookmarks"; import { requireAuth } from "@/components/admin/role-guard"; -import { BookmarkRow } from "@/components/course/bookmark-row"; import { EmptyState } from "@/components/course/empty-state"; import { PageHeader } from "@/components/course/page-header"; import { StudentShell } from "@/components/course/student-shell"; -import { BookmarkIcon } from "@/components/ui/icons"; -import type { BookmarkEnriched } from "@/lib/types"; +import { GlassCard, GlassCardContent, GlassCardTitle } from "@/components/ui/glass-card"; +import { ArrowRightIcon, BookmarkFilledIcon } from "@/components/ui/icons"; -export const metadata: Metadata = { title: "Mes favoris" }; +export const metadata: Metadata = { title: "Cours enregistrés" }; export default async function BookmarksPage() { await requireAuth(); @@ -20,17 +19,14 @@ export default async function BookmarksPage() { getMyBookmarks(), ]); - const byCourse = new Map(); + // One saved course per courseId (the bookmark is anchored on the first block). + const byCourse = new Map(); for (const b of bookmarks) { - const k = b.courseId; - if (!byCourse.has(k)) { - byCourse.set(k, { title: b.courseTitle, slug: b.courseSlug, items: [] }); + if (!byCourse.has(b.courseId)) { + byCourse.set(b.courseId, { title: b.courseTitle, slug: b.courseSlug }); } - byCourse.get(k)!.items.push(b); - } - for (const entry of byCourse.values()) { - entry.items.sort((a, b) => a.blockOrderIndex - b.blockOrderIndex); } + const courses = [...byCourse.values()]; return ( @@ -41,39 +37,31 @@ export default async function BookmarksPage() { className="mb-10" /> - {bookmarks.length === 0 ? ( - } title={t("empty")} /> + {courses.length === 0 ? ( + } title={t("empty")} /> ) : ( -
    - {[...byCourse.entries()].map(([id, group]) => ( -
    -
    -

    - - {group.title} - -

    - - {t("bookmarksCount", { count: group.items.length })} - -
    -
      - {group.items.map((b) => ( -
    • - -
    • - ))} -
    -
    +
      + {courses.map((c) => ( +
    • + + + +
      + + {t("kicker")} +
      + + {c.title} + + + {t("open")} + +
      +
      + +
    • ))} -
    +
)} ); diff --git a/apps/frontend/app/courses/[slug]/page.tsx b/apps/frontend/app/courses/[slug]/page.tsx index 73ba7bd..88ec99a 100644 --- a/apps/frontend/app/courses/[slug]/page.tsx +++ b/apps/frontend/app/courses/[slug]/page.tsx @@ -8,20 +8,15 @@ import { getCourseBySlug } from "@/app/actions/courses"; import { getMyEnrollments } from "@/app/actions/enrollments"; import { requireAuth } from "@/components/admin/role-guard"; import { CourseMeta } from "@/components/course/course-meta"; +import { CourseSaveButton } from "@/components/course/course-save-button"; import { PageHeader } from "@/components/course/page-header"; import { ProgressBar } from "@/components/course/progress-bar"; import { StatCard } from "@/components/course/stat-card"; import { StudentShell } from "@/components/course/student-shell"; import { GlassButton } from "@/components/ui/glass-button"; -import { - GlassCard, - GlassCardContent, - GlassCardTitle, -} from "@/components/ui/glass-card"; import { ArrowRightIcon, BookIcon, - BookmarkIcon, ClockIcon, PlayIcon, UsersIcon, @@ -70,12 +65,22 @@ export default async function CourseIntroPage({ params }: PageProps) { ? t("resume") : t("start"); - const allBlocks = (course.pages ?? []).flatMap((p) => p.blocks); + const sortedPages = [...(course.pages ?? [])].sort( + (a, b) => a.orderIndex - b.orderIndex + ); + const allBlocks = sortedPages.flatMap((p) => p.blocks); const blocksCount = allBlocks.length; const headings = allBlocks.filter((b) => ["H1", "H2"].includes(b.kind)); // A page maps to a lesson; fall back to headings/blocks for legacy single-page courses. const lessonCount = (course.pages?.length ?? 0) || headings.length || blocksCount; + // Course-level "save": anchored on the first block (cf. CourseSaveButton). + const firstBlockId = + [...(sortedPages[0]?.blocks ?? [])].sort( + (a, b) => a.orderIndex - b.orderIndex + )[0]?.id ?? null; + const savedId = bookmarks[0]?.id ?? null; + return ( @@ -87,13 +92,23 @@ export default async function CourseIntroPage({ params }: PageProps) { title={course.title} description={course.description} actions={ - - - - {ctaLabel} - - - + <> + + + + {ctaLabel} + + + + + } className="mb-8" /> @@ -142,47 +157,6 @@ export default async function CourseIntroPage({ params }: PageProps) { {t("publishedOn", { date: formatDate(course.publishedAt, locale) })}

)} - -
-

- {t("bookmarksTitle")} -

- {bookmarks.length === 0 ? ( - - -

{t("bookmarksEmpty")}

- - {t("openReader")} - -
- ) : ( -
    - {bookmarks.map((b) => ( -
  • - - - -
    - - {b.blockKind} · #{b.blockOrderIndex + 1} -
    - - {b.blockPreview ?? `Bloc ${b.blockOrderIndex + 1}`} - -
    -
    - -
  • - ))} -
- )} -
); } diff --git a/apps/frontend/app/courses/[slug]/read/page.tsx b/apps/frontend/app/courses/[slug]/read/page.tsx index 32a9f0e..610c3c9 100644 --- a/apps/frontend/app/courses/[slug]/read/page.tsx +++ b/apps/frontend/app/courses/[slug]/read/page.tsx @@ -3,12 +3,10 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { getCourseBookmarks } from "@/app/actions/bookmarks"; import { getCourseBySlug } from "@/app/actions/courses"; import { requireAuth } from "@/components/admin/role-guard"; import { BlockRenderer } from "@/components/course/block-renderer"; import { BlockToc, blockSlug } from "@/components/course/block-toc"; -import { BookmarkButton } from "@/components/course/bookmark-button"; import { MobileToc } from "@/components/course/mobile-toc"; import { StudentShell } from "@/components/course/student-shell"; import { GlassButton } from "@/components/ui/glass-button"; @@ -50,11 +48,6 @@ export default async function CourseReaderPage({ params, searchParams }: PagePro const course = await getCourseBySlug(slug); if (!course) notFound(); - const bookmarks = await getCourseBookmarks(course.id); - const bookmarkMap = new Map( - bookmarks.map((b) => [b.blockId, b.id]) - ); - const pages = [...(course.pages ?? [])] .sort((a, b) => a.orderIndex - b.orderIndex) .map((p) => ({ @@ -173,21 +166,9 @@ export default async function CourseReaderPage({ params, searchParams }: PagePro ) : ( -
+
{pageBlocks.map((b) => ( -
-
- -
- -
+ ))}
)} diff --git a/apps/frontend/app/courses/page.tsx b/apps/frontend/app/courses/page.tsx index 9528576..49d45c0 100644 --- a/apps/frontend/app/courses/page.tsx +++ b/apps/frontend/app/courses/page.tsx @@ -45,13 +45,6 @@ export default async function CoursesCatalogPage() { diff --git a/apps/frontend/app/dashboard/page.tsx b/apps/frontend/app/dashboard/page.tsx new file mode 100644 index 0000000..b7bef9b --- /dev/null +++ b/apps/frontend/app/dashboard/page.tsx @@ -0,0 +1,19 @@ +import { redirect } from "next/navigation"; + +import { requireAuth } from "@/components/admin/role-guard"; +import { isAdmin, isStaff } from "@/lib/roles"; + +/** + * Role dispatcher — the single source of truth for "where does a logged-in + * user land". Post-login and the public "/" send authenticated users here. + * ADMIN / SUPER_ADMIN → /admin + * TEACHER → /studio + * STUDENT → /learn + */ +export default async function DashboardPage() { + const me = await requireAuth(); + + if (isAdmin(me.role)) redirect("/admin"); + if (isStaff(me.role)) redirect("/studio"); + redirect("/learn"); +} diff --git a/apps/frontend/app/globals.css b/apps/frontend/app/globals.css index d211660..6422bae 100644 --- a/apps/frontend/app/globals.css +++ b/apps/frontend/app/globals.css @@ -1,54 +1,77 @@ @import "tailwindcss"; +/* ============================================================ + Codestar — Design System "Liquid Glass · Citron" + Source de vérité : apps/frontend/design-liquid-glass-citron-dark.md + Accent citron #EAB12E · modes clair (parchemin) & sombre (navy). + ============================================================ */ + +/* ── Tokens fixes (indépendants du mode) ── */ :root { - --color-bg-base-raw: #f4f6fb; - --color-bg-mesh-1-raw: #dce8ff; - --color-bg-mesh-2-raw: #ffe4d6; - --color-bg-mesh-3-raw: #e1f5e8; + --color-accent-raw: #eab12e; + --color-accent-fg-raw: #1a1f2e; + --glass-blur: blur(29px) saturate(180%); + + --r-sm: 8px; + --r: 14px; + --r-lg: 22px; + --r-xl: 32px; + --radius: 0.875rem; +} + +/* ── Mode clair (défaut) — parchemin solaire ── */ +:root, +[data-theme="light"] { + color-scheme: light; + + --color-bg-base-raw: #fbf9ee; + --color-bg-mesh-1-raw: #fff1bf; + --color-bg-mesh-2-raw: #ffe2be; + --color-bg-mesh-3-raw: #ebf6c8; --glass-bg: rgba(255, 255, 255, 0.55); --glass-bg-strong: rgba(255, 255, 255, 0.72); --glass-border: rgba(255, 255, 255, 0.65); - --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.08); - --glass-blur: blur(20px) saturate(180%); + --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.1); --color-text-raw: #1a1f2e; --color-text-soft-raw: #4a5366; --color-muted-raw: #8892a6; - --color-accent-raw: #7aa9ff; - --color-accent-fg-raw: #ffffff; - --color-accent-soft-raw: rgba(122, 169, 255, 0.18); + --color-accent-soft-raw: rgba(234, 177, 46, 0.16); - --color-success-raw: #5dc9a8; - --color-warning-raw: #ffb672; - --color-danger-raw: #ff8a95; - --color-green-raw: #7bc86c; - --color-tip-raw: #ffd66b; - - --r-sm: 8px; - --r: 14px; - --r-lg: 22px; - --r-xl: 32px; - --radius: 0.875rem; + --color-success-raw: #2faa7e; + --color-warning-raw: #e08a2b; + --color-danger-raw: #e0556a; + --color-green-raw: #5aa84a; + --color-tip-raw: #d6a01f; } +/* ── Mode sombre — navy profond ── */ [data-theme="dark"] { + color-scheme: dark; + --color-bg-base-raw: #0e1422; - --color-bg-mesh-1-raw: #1a2440; - --color-bg-mesh-2-raw: #2a1f30; - --color-bg-mesh-3-raw: #14283a; + --color-bg-mesh-1-raw: #38352a; + --color-bg-mesh-2-raw: #332d26; + --color-bg-mesh-3-raw: #2f3128; --glass-bg: rgba(20, 28, 48, 0.55); - --glass-bg-strong: rgba(20, 28, 48, 0.78); - --glass-border: rgba(255, 255, 255, 0.1); + --glass-bg-strong: rgba(20, 28, 48, 0.8); + --glass-border: rgba(255, 255, 255, 0.12); --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); --color-text-raw: #edf1f9; --color-text-soft-raw: #b6c0d6; --color-muted-raw: #7c8ba8; - --color-accent-soft-raw: rgba(122, 169, 255, 0.22); + --color-accent-soft-raw: rgba(234, 177, 46, 0.24); + + --color-success-raw: #5dc9a8; + --color-warning-raw: #ffb672; + --color-danger-raw: #ff8a95; + --color-green-raw: #7bc86c; + --color-tip-raw: #ffd66b; } @theme inline { @@ -91,19 +114,12 @@ --radius-xl: calc(var(--radius) + 4px); --font-sans: - var(--font-outfit), -apple-system, BlinkMacSystemFont, "Segoe UI", + var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-display: - var(--font-instrument-serif), "Fraunces", Georgia, serif; + --font-display: var(--font-fraunces), "Fraunces", Georgia, serif; --font-mono: - "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; -} - -:root { - color-scheme: light; -} -[data-theme="dark"] { - color-scheme: dark; + var(--font-jetbrains-mono), "JetBrains Mono", ui-monospace, SFMono-Regular, + Menlo, monospace; } html { @@ -127,7 +143,7 @@ body { color: var(--color-accent-fg-raw); } - +/* ── Surfaces verre ── */ .glass { background: var(--glass-bg); border: 1px solid var(--glass-border); @@ -155,6 +171,29 @@ body { border-radius: var(--r-lg); } +/* Pseudo-reflet linéaire — cards héro / vcard (design §5) */ +.glass-reflect { + position: relative; + isolation: isolate; +} +.glass-reflect::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient( + 135deg, + color-mix(in oklab, #ffffff 50%, transparent), + transparent 42% + ); + pointer-events: none; + z-index: 0; +} +.glass-reflect > * { + position: relative; + z-index: 1; +} + /* Focus ring — non-supprimable (a11y WCAG AA) */ :where(a, button, input, textarea, select, [tabindex]):focus-visible { outline: 2px solid var(--color-accent-raw); @@ -162,6 +201,7 @@ body { border-radius: var(--r-sm); } +/* ── Animations (design §6) ── */ @keyframes mesh-drift { 0%, 100% { @@ -175,23 +215,197 @@ body { } } +@keyframes gh-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + .mesh-spot { will-change: transform; } +.gh-bob { + animation: gh-bob 7s ease-in-out infinite; +} +.gh-bob-1 { + animation: gh-bob 6s ease-in-out infinite 0.4s; +} +.gh-bob-2 { + animation: gh-bob 8s ease-in-out infinite 0.8s; +} + .font-display { font-family: var(--font-display); - font-weight: 400; + font-weight: 500; letter-spacing: -0.01em; - line-height: 1.02; + line-height: 1.08; } .font-mono { font-family: var(--font-mono); } +/* ============================================================ + Landing FX — effets visuels des pages publiques (home, auth). + Tous gated par prefers-reduced-motion (cf. bloc plus bas). + ============================================================ */ + +/* Entrée en cascade : opacité + montée + flou qui se résorbe. */ +@keyframes fx-rise { + from { + opacity: 0; + transform: translateY(24px); + filter: blur(8px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +} +.fx-rise { + opacity: 0; + animation: fx-rise 0.95s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* CTA : halo accent pulsé. */ +@keyframes fx-cta-glow { + 0%, + 100% { + box-shadow: + 0 8px 28px -6px color-mix(in oklab, var(--color-accent) 55%, transparent); + } + 50% { + box-shadow: + 0 16px 46px -4px color-mix(in oklab, var(--color-accent) 82%, transparent); + } +} +.fx-cta-glow { + animation: fx-cta-glow 3.2s ease-in-out infinite; +} + +/* Balayage de lumière au survol des surfaces verre. */ +.fx-sheen { + position: relative; + overflow: hidden; +} +.fx-sheen::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 115deg, + transparent 30%, + color-mix(in oklab, #fff 55%, transparent) 50%, + transparent 70% + ); + transform: translateX(-130%); + transition: transform 0.75s ease; + pointer-events: none; +} +.fx-sheen:hover::after { + transform: translateX(130%); +} + +/* Aurora — faisceau conique en rotation lente. */ +@keyframes fx-aurora { + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} +.fx-aurora { + animation: fx-aurora 30s linear infinite; + will-change: transform; +} + +/* Orbes flottants. */ +@keyframes fx-orb { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(0, -26px, 0) scale(1.08); + } +} +.fx-orb { + animation: fx-orb 9s ease-in-out infinite; + will-change: transform; +} + +/* Particules scintillantes. */ +@keyframes fx-twinkle { + 0%, + 100% { + opacity: 0.12; + transform: scale(0.6); + } + 50% { + opacity: 0.85; + transform: scale(1.2); + } +} +.fx-particle { + animation: fx-twinkle 4.5s ease-in-out infinite; + will-change: opacity, transform; +} + +/* Apparition « pop » élastique — coche de validation live. */ +@keyframes fx-pop { + 0% { + opacity: 0; + transform: scale(0.2) rotate(-22deg); + } + 60% { + opacity: 1; + transform: scale(1.18) rotate(7deg); + } + 100% { + opacity: 1; + transform: scale(1) rotate(0); + } +} +.fx-pop { + animation: fx-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +/* Reflet qui circule dans la piste « glisser pour confirmer ». */ +@keyframes fx-track-flow { + to { + background-position: 200% center; + } +} +.fx-track-flow { + background-image: linear-gradient( + 100deg, + color-mix(in oklab, var(--color-accent) 70%, transparent) 0%, + color-mix(in oklab, var(--color-accent) 100%, white 12%) 50%, + color-mix(in oklab, var(--color-accent) 70%, transparent) 100% + ); + background-size: 200% auto; + animation: fx-track-flow 1.6s linear infinite; +} + @media (prefers-reduced-motion: reduce) { - .mesh-spot { + .mesh-spot, + .gh-bob, + .gh-bob-1, + .gh-bob-2, + .fx-aurora, + .fx-orb, + .fx-particle, + .fx-cta-glow, + .fx-track-flow { + animation: none !important; + } + .fx-rise, + .fx-pop { + opacity: 1; animation: none !important; } html { @@ -209,4 +423,7 @@ body { border-color: var(--color-text-raw); box-shadow: none; } + .glass-reflect::before { + display: none; + } } diff --git a/apps/frontend/app/layout.tsx b/apps/frontend/app/layout.tsx index dbd23e1..318750b 100644 --- a/apps/frontend/app/layout.tsx +++ b/apps/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; -import { Outfit, Instrument_Serif } from "next/font/google"; +import { Fraunces, Inter, JetBrains_Mono } from "next/font/google"; +import { cookies } from "next/headers"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; @@ -7,26 +8,36 @@ import { getMe } from "@/app/actions/auth"; import { getInstanceBranding } from "@/app/actions/instance"; import { AuthProvider } from "@/components/auth-provider"; import { BrandingProvider } from "@/components/branding-provider"; +import { ThemeProvider } from "@/components/theme-provider"; +import { ThemeScript } from "@/components/theme-script"; import { MeshBackground } from "@/components/ui/mesh-background"; import { SITE_URL } from "@/lib/site"; +import { DEFAULT_THEME, isTheme, resolveTheme, THEME_COOKIE } from "@/lib/theme"; import "./globals.css"; -const outfit = Outfit({ - variable: "--font-outfit", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], - weight: ["300", "400", "500", "600", "700", "800", "900"], + weight: ["400", "500", "600", "700"], display: "swap", }); -const instrumentSerif = Instrument_Serif({ - variable: "--font-instrument-serif", +const fraunces = Fraunces({ + variable: "--font-fraunces", subsets: ["latin"], - weight: ["400"], + weight: ["400", "500", "600"], style: ["normal", "italic"], display: "swap", }); +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", + subsets: ["latin"], + weight: ["400", "500", "700"], + display: "swap", +}); + export const metadata: Metadata = { metadataBase: new URL(SITE_URL), title: { @@ -102,17 +113,23 @@ const jsonLd = { export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { - const [locale, messages, branding, me] = await Promise.all([ + const [locale, messages, branding, me, cookieStore] = await Promise.all([ getLocale(), getMessages(), getInstanceBranding(), getMe(), + cookies(), ]); + const themeCookie = cookieStore.get(THEME_COOKIE)?.value; + const theme = isTheme(themeCookie) ? themeCookie : DEFAULT_THEME; + const resolvedTheme = resolveTheme(theme); + return ( +