diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..97f0e81 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "prisma.pinToPrisma6": true +} diff --git a/AGENTS.md b/AGENTS.md index 650701c..8029dc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -✅ OPEN SOLVE — MASTER REDESIGN & IMPLEMENTATION PROMPT +# OPENSOLVE — DESIGN SYSTEM & IMPLEMENTATION GUIDE (Cursor Agent Spec) You are the **principal product/UI engineer for OpenSolve**, an open-source LeetCode alternative. @@ -8,444 +8,617 @@ Your job is to **completely redesign** the given page(s) and all of their nested - **Tailwind CSS** - **Customized shadcn/ui components** - **TanStack Table** -- **hugeicons-react** for all icons (see Section 4.4) +- **hugeicons-react** for all icons This is NOT a tweak or refinement. -This is a **full visual + UX rebuild** while keeping the existing data structures, hooks, and behavior intact. +This is a **full visual + UX rebuild** while keeping the **existing data structures, hooks, and behavior intact**. --- -## 0. Project Context (OpenSolve) +## [00] ROLE & PRIORITIES + +**Role:** +You are responsible for the **UI/UX layer** only. You may reorganize components, JSX, and Tailwind classes, but: + +1. **Do NOT break logic** + - Keep existing hooks, tRPC calls, React Query usage, and business rules. + - Keep prop names and types unless explicitly told otherwise. + +2. **Absolute MUSTs (never violate):** + - `font-mono` for **all** text (headings, labels, body, buttons). + - **No rounded corners** anywhere → always `rounded-none`. + - **Semantic Tailwind tokens only** (`bg-background`, `text-foreground`, etc.). + - **hugeicons-react only** for icons (via `@/components/icons` or direct imports). + - Designs must work in **both light and dark mode**. + - All marketing/UX copy should be configurable (via `config/*.ts`), not hard-coded in components. + +3. **When redesigning a page, output in this structure:** + 1. **Brief UX Plan** (4–6 bullets). + 2. **Main Page Component** (full TSX with imports). + 3. **Nested Components** (any additional components you introduce). + 4. **Config File** (if applicable; put strings/copy here). + 5. **Notes** (integration points, assumptions, domain considerations). + +--- + +## [01] PROJECT CONTEXT (OPENSOLVE) OpenSolve is: -- A competitive programming / algorithmic practice platform -- Built with **Next.js**, **PostgreSQL/Prisma**, **tRPC**, **React Query**, **Tailwind**, and **shadcn/ui** -- Uses a **RabbitMQ + Docker judge** for submissions -- Has rich domain entities: +- A **competitive programming / algorithmic practice platform**. +- Built with **Next.js**, **PostgreSQL/Prisma**, **tRPC**, **React Query**, **Tailwind**, and **shadcn/ui**. +- Uses a **RabbitMQ + Docker judge** (plus an inline simulator fallback) for submissions. +- Includes rich domain entities like: - `User`, `Problem`, `ProblemVersion`, `Tag`, `Difficulty`, `Company` - `Submission`, `Language`, `Verdict`, `TestCase` - `Contest`, `ContestProblem`, `ContestRegistration`, `ContestSnapshot`, `ContestClarification` - `Discussion`, `TrailInsight`, `Badge`, `Leaderboard*`, `SystemSetting`, `Incident`, etc. -Everything you design must feel **native to this domain**: -coding, problems, submissions, contests, leaderboards, discussions, admin tooling. +Everything you design must feel **native** to this domain: + +> Coding, problems, submissions, contests, leaderboards, discussions, staff tools, admin consoles. --- -## 1. Goal: Complete Redesign (Not Modification) +## [02] DESIGN LANGUAGE — “BRUTALIST TERMINAL” -**Do NOT**: +### 2.1 Core Aesthetic -- Do not preserve or slightly improve the current layout. -- Do not keep the same structure and just reskin. -- Do not keep “placeholder” structures from the old UI. +OpenSolve uses a **brutalist, terminal-inspired** design. It should feel like a **serious developer tool**, not a generic SaaS. -**Instead**: +**Key principles:** -- **Replace the entire page UI and all nested components** with a new, intentional, premium OpenSolve design. -- Create a layout and visual hierarchy that looks handcrafted and product-grade—**not** like a generic AI/Tailwind template. +- **No rounded corners** + - Always use `rounded-none` (buttons, cards, chips, panels, inputs). +- **Sharp borders** + - Emphasis: `border-2 border-border` or `border-2 border-primary`. + - Dividers: `border border-border`. +- **Monospace typography** + - `font-mono` for **all** text, including headings and buttons. +- **Numbered sections** + - Use bracket notation: `[01]`, `[02]`, `[03]` for major sections. +- **Functional minimalism** + - No decorative flourishes for their own sake. + - Let grid, spacing, typography, and subtle gradients carry the visual weight. +- **Magazine-style layouts** + - Asymmetric grids, bold headings, strong hierarchy. -The new UI must make people think: +--- -> “There’s no way AI built this. This looks like a serious, professionally designed product.” +## [03] COLOR SYSTEM — SEMANTIC ONLY ---- +### 3.1 Semantic Tokens -## 2. Global Layout & Information Architecture (Enforce These Patterns) +**Never** use raw hex colors or Tailwind’s gray/blue palettes (`text-gray-900`, `bg-white`, etc.). +Only use semantic tokens (which are mapped in CSS for light/dark modes): -OpenSolve uses a **unified app shell**: +- `bg-background` / `text-foreground` — page background and primary text. +- `text-muted-foreground` — secondary text. +- `bg-card` / `text-card-foreground` — card surfaces. +- `bg-primary` / `text-primary-foreground` — primary actions and CTAs. +- `text-primary` — emphasis text. +- `bg-accent` — subtle backgrounds & hovers. +- `border-border` — all default borders. +- State colors (if defined): + - `bg-success`, `text-success`, `bg-success/10`, `border-success/30` + - `bg-warning`, `text-warning`, `bg-warning/10`, `border-warning/30` + - `bg-destructive`, `text-destructive`, `bg-destructive/10`, `border-destructive/30` + - `bg-info`, `text-info`, `bg-info/10`, `border-info/30` -- **Left Sidebar** → main navigation (Problems, Submissions, Contests, Discussions, Trails, Profile, Admin, etc.) -- **Topbar** → page context and actions +### 3.2 Gradients & Shadows (Subtle) -For any **authenticated/internal page** you redesign: +Use Tailwind’s gradient utilities (or equivalent custom ones) **sparingly**, mostly for headings and hero sections: -1. **Sidebar (persistent)** - - Implement a clean, slim left sidebar for primary navigation. - - Include: - - Product logo/name - - Main nav items (Problems, Contests, Submissions, Leaderboard, Discussions, Trails) - - Admin/Staff area (only if roles permit) - - Use **icon + label** patterns (icons from `hugeicons-react`). - - Support collapse/minify on smaller screens (`md:` up). +- **Text gradients (headlines):** -2. **Topbar (always present above content)** - Use the top bar to convey: - - Page **title** and **subtitle** or short context. - - **Breadcrumbs** (e.g., Problems → Two Sum → Submissions). - - Primary **page-level actions**: “New Contest”, “Create Problem”, “Edit”, “Upsolve”, etc. - - User identity: avatar + small dropdown. - - Optional environment/light indicators (e.g., `Dev`, `Staging` badge), if reasonable. +```tsx +className = + "bg-gradient-to-br from-foreground via-foreground to-foreground/70 bg-clip-text text-transparent"; +``` + +- **Primary accent text:** + + `className="bg-gradient-to-r from-primary via-primary to-primary/70 bg-clip-text text-transparent"` + +- **Subtle background gradient:** -3. **Content Area** - - Below the topbar, design a **clear main content layout**: - - For simple detail pages: single column + side rail. - - For complex pages (problem solving, contest dashboards): **split panes**, sticky headers, and clear sections. + `className="bg-gradient-to-br from-primary/5 via-background to-background"` -4. **Responsiveness** - - On small screens: - - Sidebar collapses into a top menu or overlay. - - Topbar remains, but simplified. - - Content stacks vertically, no horizontal scroll. +- **Hover gradient overlay (e.g. cards):** -Use these patterns **consistently** across all redesigns unless a page is explicitly public/standalone (e.g., landing page, auth screens). + `
` + +- **Shadows:** + - Use `shadow-sm shadow-primary/10`, `shadow-md shadow-primary/20`, etc. + - Keep them subtle; this is not a neumorphic UI. --- -## 3. Visual Direction & Design System +## [04] TYPOGRAPHY & SPACING -### 3.1 Color System — Modern Bright SaaS (Blue Primary) +### 4.1 Typography Scale (all `font-mono`) -Use a **Modern Bright SaaS** style: +- **Hero display:** -- **Primary**: Blue (mid-saturated, not neon, not dull). - Think something in the range of Tailwind `blue-500` / `blue-600` for primary actions, with lighter tints for backgrounds/badges. -- **Neutrals**: A refined gray scale (e.g., slate/stone) for backgrounds, borders, and typography. -- **Accents** (used sparingly): - - Green for success (AC, RESOLVED, ACTIVE) - - Amber/orange for warnings (RETRYING, OFFSET, PENDING, FREEZE) - - Red/pink for errors/critical (FAILED, REJECTED, SEV1/SEV2) - - Purple or cyan for “special” or “featured” states (editorials, Trails, badges) + `className="font-mono text-6xl sm:text-7xl lg:text-8xl font-black leading-[1.05] tracking-tighter"` -Support **both light and dark mode**: +- **Section headlines:** -- Use Tailwind `dark:` variants and/or CSS variables. -- Avoid hard-coded colors like `#000`, `#fff`; instead rely on semantic tokens and classes (e.g. `bg-background`, `text-foreground`, `border-border` if using shadcn theme). + `className="font-mono text-4xl sm:text-5xl lg:text-6xl font-black leading-tight tracking-tight"` -### 3.2 Typography +- **Subsection titles:** -- Use a clean, modern sans-serif. -- Establish a clear hierarchy: - - Page title: `text-2xl` / `text-3xl`, `font-semibold` - - Section headings: `text-lg` / `text-xl`, `font-semibold` - - Body: `text-sm` / `text/base`, comfortable line-height - - Meta text: `text-xs` / `text-[13px]`, muted -- Use consistent letter spacing and alignment. No random mix of weights. + `className="font-mono text-xl font-bold"` -### 3.3 Spacing & Layout Rhythm +- **Body text:** -- Use an **8-point spacing system**: 4, 8, 12, 16, 20, 24, etc. -- Prefer Tailwind spacing tokens (`p-4`, `px-6`, `gap-4`, `gap-6`) over arbitrary pixels. -- Use `max-w-*` and `mx-auto` for constrained layouts where it makes sense (e.g. auth pages, simple forms). + `className="font-mono text-base leading-relaxed"` -### 3.4 Surfaces & Depth +- **Small / labels:** -- Cards and panels should be: - - Subtly rounded (`rounded-xl`, `rounded-2xl` for primary surfaces). - - Light shadows (`shadow-sm`, `shadow-md`), not heavy, with subtle contrast between layers. -- Use separators and soft borders (`border-border`) to structure dense information (tables, timelines, details). + `className="font-mono text-sm"` ---- +- **Meta / micro text:** -## 4. Components, Icons & Libraries (OpenSolve Constraints) + `className="font-mono text-xs text-muted-foreground"` -### 4.1 Tech Stack +- **Section markers:** -- **Next.js + TypeScript** -- **Tailwind CSS** -- **shadcn/ui components (customized)** -- **TanStack Query (React Query) over tRPC** -- **TanStack Table** for data tables -- **hugeicons-react** for all icons (see below) + `className="font-mono text-xs font-bold text-primary/80"` -Do NOT introduce: +### 4.2 Spacing System (8px base) -- New UI libraries (no MUI, Chakra, DaisyUI, etc.). -- New motion libraries (no Framer Motion, no GSAP). -- Heavy CSS frameworks beyond Tailwind. +Use Tailwind spacing aligned with 8px increments: -### 4.2 shadcn/ui +- `gap-2` (8px), `gap-3` (12px), `gap-4` (16px), `gap-6` (24px), `gap-8` (32px), `gap-12` (48px). +- `p-4`, `p-6`, `p-8`, `p-12` for padding. +- Section vertical spacing: `py-24 lg:py-32` for major sections. -- Use shadcn components as your base: `Button`, `Card`, `Tabs`, `Dialog`, `DropdownMenu`, `Badge`, `Tooltip`, `Skeleton`, `Alert`, `Toast`, `Form`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, `Tabs`, `ScrollArea`, etc. -- **Customize them** to match the OpenSolve design system, instead of using vanilla shadcn styles: - - Adjust radii, colors, typography via classNames/tokens. - - Ensure consistency across all redesigned pages. +--- -### 4.3 Data Tables — Use TanStack Table +## [05] CORE COMPONENT PATTERNS -For any list-heavy / admin / analytics page: +### 5.1 Buttons (shadcn/ui-based) -- Use the **existing TanStack Table abstraction** (e.g. a `DataTable` component already in the codebase). -- Do NOT hand-roll random HTML tables when a proper data table is needed. -- Enhance table UX with: - - Sticky headers - - Row hover states - - Clear sorting indicators - - Optional column visibility toggles - - Search & filters integrated into a table toolbar - - Pagination controls that feel native to the design +**Primary CTA:** -### 4.4 Icon System — Migrate to `hugeicons-react` (No `lucide-react`) +```jsx + +``` -OpenSolve is moving from `lucide-react` to **`hugeicons-react`**. -You must: +**Secondary / Outline:** -1. **Never import from `lucide-react`.** - - Remove all existing lucide imports. - - Do not introduce new ones. - - Replace every lucide icon with a `hugeicons-react` icon. +```jsx + +``` -2. **Use `hugeicons-react` for every icon**: - - Navigation icons (sidebar, topbar) - - Action icons (buttons, fab, menus) - - Status icons (success, error, warning, info) - - Domain icons (problems, contests, submissions, discussions, leaderboard, admin, incidents, settings, feature flags, judge, queues, etc.) +**Ghost / text button:** -3. **If you don’t know which `hugeicons-react` icon to use or how to import it**, you MUST: - - Perform an internet search to look up: - - The `hugeicons-react` package, - - Its icon list and naming conventions, - - The appropriate icon component name and import syntax. - - Then choose the most semantically appropriate icon (e.g. problem→code/algorithm icon, contests→trophy/flag, submissions→checklist or code-run icon, incidents→alert, etc.). +```jsx + +``` -4. **Consistency**: - - Use a consistent size (e.g. `className="h-4 w-4"` or `h-5 w-5`) for icons in a given context. - - Use consistent stroke/fill style (outlined vs filled) for icons in the same UI area. - - Use Tailwind utility classes to align icons (`inline-flex`, `items-center`, `gap-2`). +**Icon button:** -Example (illustrative, not exact): +```jsx + +``` -```tsx -import { CodeCircle01Icon, Trophy01Icon } from "hugeicons-react"; +### 5.2 Cards / Panels + +**Default card:** -; +```jsx +
{/* content */}
``` -Apply this migration & usage pattern consistently in all redesigned components. +**Interactive card with hover highlight:** ---- +```jsx +
+ {" "} +
{" "} +
{/* content */}
+
+``` -## 5. States & Domain-Specific Status Handling +### 5.3 Section Headers -You must **explicitly handle all relevant states** for the page you are redesigning. That includes both generic UX states and OpenSolve’s domain states. +```jsx +
+ [01] SECTION NAME

+ SECTION HEADLINE

+``` -### 5.1 Generic UX States (Always Design For) +### 5.4 Stats -For every major section (lists, details, forms, editor, panels): +```jsx +
+ {" "} +
+ 50K+{" "} +
{" "} +
DEVELOPERS
+
+``` -- **Loading state** → skeletons, shimmer, skeleton rows/cards. -- **Empty state** → friendly, domain-aware copy and a clear primary action (e.g. “Create your first contest”, “No submissions yet — try solving a problem”). -- **Error state** → clear message, optional “show technical details” toggle, retry button. -- **Partial/Degraded state** → e.g. when the judge is down but the editor still works. -- **Permission/Role gate** → e.g. curated tools visible only to `PROBLEM_CURATOR`/`ADMIN`. -- **Offline/Network issues** (if appropriate) → subtle banner about connectivity. +### 5.5 Grid with Pixel Dividers -### 5.2 OpenSolve Domain States (Map To Visual Tokens) +```jsx +
+ {" "} +
Item 1
{" "} +
Item 2
{" "} +
Item 3
+
+``` -Map important enums to **badges, chips, or color-coded text**. You don’t need to hard-code every case in JSX in this prompt, but your design must assume and support them. +--- -Examples: +## [06] LAYOUT PATTERNS -- `UserStatus`: `ACTIVE`, `BANNED`, `SHADOW_BANNED` -- `UserRole`: `USER`, `PROBLEM_CURATOR`, `ADMIN`, `MODERATOR` -- `ProblemState`: `DRAFT`, `REVIEW`, `PUBLISHED`, `ARCHIVED` -- `ProblemVisibility`: `PUBLIC`, `UNLISTED`, `INTERNAL` -- `ProblemJudgeMode`: `AUTO`, `MANUAL`, `HYBRID` -- `TestCaseKind`: `SAMPLE`, `HIDDEN` -- `SubmissionStatus`: `QUEUED`, `RUNNING`, `SUCCEEDED`, `FAILED`, `RETRYING`, `MANUAL_PENDING` -- `ContestState`: `UPCOMING`, `RUNNING`, `FINISHED`, `ARCHIVED` -- `ContestVisibility`: `PUBLIC`, `PRIVATE` -- `ContestType`: `COMPETITIVE`, `EDUCATIONAL`, `PRIVATE`, `CUSTOM` -- `DiscussionState`: `VISIBLE`, `HIDDEN`, `REMOVED` -- `IncidentStatus`: `OPEN`, `INVESTIGATING`, `MITIGATED`, `MONITORING`, `RESOLVED` -- `IncidentSeverity`: `SEV1`, `SEV2`, `SEV3` +### 6.1 Top Navigation (Marketing / Public) -General mapping guidelines: +```html + +``` -- **Positive or terminal-good** (e.g. `SUCCEEDED`, `PUBLISHED`, `ACTIVE`, `RESOLVED`) → green/blue badges. -- **Warning/liminal** (e.g. `REVIEW`, `RETRYING`, `UPCOMING`, `MANUAL_PENDING`, `MITIGATING`) → amber/yellow badges. -- **Danger/bad** (e.g. `FAILED`, `REJECTED`, `BANNED`, `SEV1`) → red badges. -- **Internal/system-only** or muted states (e.g. `INTERNAL`, `ARCHIVED`, `SHADOW_BANNED`) → gray/muted badges. +### 6.2 App Shell (Authenticated) + +- Use existing `app-shell` / layout wrappers when present. +- Left sidebar with icons + labels. +- Collapsible on mobile. + +### 6.3 Hero Sections + +```jsx +
+ {" "} +
+ {" "} +
+ {" "} + + {config.hero.badge}{" "} +
{" "} +

+ {config.hero.headline.line1}
+ {config.hero.headline.line2}
{" "} + + {config.hero.headline.line3}{" "} + {" "} +

{" "} +

{config.hero.description}

{" "} +
{/* Primary + secondary CTAs */}
{" "} +
{" "} +
{/* Feature visual / split editor / stats */}
+
+``` -Always ensure state is conveyed via **color + text + shape** (for accessibility). +### 6.4 Split Pane (Problem / Submission Pages) + +```jsx +
+ {" "} +
+ {/* Left pane: problem, meta, tests */}{" "} +
{" "} +
+ {/* Right pane: editor, console, verdicts */}{" "} +
+
+``` --- -## 6. Page Types & UX Patterns (Use When Relevant) +## [07] INTERACTIVE COMPONENTS + +### 7.1 Command Palette (⌘K) + +**Expected for all main app contexts.** -When the page you’re given is one of these, follow these patterns: +- Opens with **⌘K** (Mac) / **Ctrl+K** (Windows/Linux). +- Keyboard navigation: arrow keys, Enter to select, ESC to close. +- Style: `rounded-none`, semantic colors, `font-mono`. -- **Problem Library / Reader** - - Left column: filters (difficulty, tags, companies, status). - - Main: responsive grid or list of problems, status chips, acceptance rate, difficulty badges. - - Strong search & filter UX, with sticky controls on larger screens. +Implementation hint: -- **Problem Details + Editor** - - **Split layout**: statement pane and code editor pane. - - Statement: tabs for description, constraints, examples, editorial (if unlocked). - - Editor: language switcher, boilerplate, run vs submit, status strip with verdict and runtime. - - Persist drafts per problem + language. +`// components/marketing/command-palette.tsx // or components/app/command-palette.tsx` -- **Submissions & History** - - Use **TanStack Table** with sortable columns (problem, verdict, time, language, runtime, memory). - - Filters for verdict, language, date range, contest vs practice. - - Row click → submission detail pane/page with code + case breakdown. +### 7.2 Theme Toggle -- **Contests (builder, dashboard, standings)** - - Contest builder → **multi-step form** (metadata → schedule → settings → problems → review). - - Standings → table with rank, handle, solves, penalty, with freeze state handled visually. - - Clear contest state (UPCOMING/RUNNING/FINISHED), countdown timers, registration state. +Always include a theme toggle in nav / app shell: -- **Discussions & Trails** - - Thread layout with clear indentation, reply counts, voting buttons, spoiler handling. - - Trail insights as structured cards with categories and upvotes, maybe a graph or relationship hints. +```jsx + +``` + +### 7.3 Tables (TanStack Table) + +For problems, submissions, users, contests, etc: + +```jsx + + {" "} + + {/* Filters, view toggles, etc. */}{" "} +
+ } +/> +``` -- **Admin Panel** - - Use a **denser layout**, but still breathable. - - Overview metrics at top (incidents open, queue depth, recent failures). - - Tabs for Users, Problems, Submissions, Contests, Discussions, Incidents, Feature Flags, System Settings. - - Remember: **Admin can do everything**. Show powerful tools but with clear warnings and safeguards. +Styling guidelines: -Apply what’s relevant to the specific page you are redesigning. +- Header row: `bg-muted font-mono text-xs font-bold uppercase`. +- Row: `border-b border-border hover:bg-accent`. +- Cells: `px-4 py-3 font-mono text-sm`. --- -## 7. Forms, Modals, and Feedback +## [08] STATES & DOMAIN BADGES + +### 8.1 Generic UX States + +**Loading** → skeletons, not spinners: -### 7.1 Forms +`
` -If the page includes a non-trivial form (contest builder, problem editor, settings, etc.): +**Empty** → clear message + action: -- For long/complex forms → convert into a **multi-step flow** with: - - Progress indicator - - Clear section grouping - - “Next” / “Back” actions +```jsx +
+ {" "} +
No problems found
{" "} + +
+``` -- Use shadcn `Form` with proper label, description, and error text. -- Show inline validation and top-level error summary when needed. -- Mobile-friendly: no cramped inputs, adequate tap targets. +**Error** → border + background: -### 7.2 Modals (Preferred) vs Drawers +```jsx +
+ {error.message} +
+``` -- Prefer **modals (Dialogs)** over drawers/sheets. -- Use shadcn `Dialog` for: - - Confirmation flows - - Quick create/edit forms - - Dangerous actions (delete problem, ban user, rejudge contest, etc.) +### 8.2 Domain-Specific Badges -- Only use drawers/sheets if absolutely necessary for mobile or workflows that must remain anchored. +Use `Badge` from shadcn, customized: -### 7.3 Toasts / Feedback System +` STATUS ` -Every redesigned page must integrate the existing or new **toast system** using shadcn (or compatible pattern): +**Verdict:** -- Types: - - success - - error/failure - - warning - - info +- `SUCCEEDED` → `AC` (Accepted): -- Toasts must: - - Be accessible (ARIA labels, understandable message). - - Be responsive (sane max-width on mobile). - - Have distinct but subtle styling per variant. + ` AC ` -Provide **example toast usages** in your response for key flows (e.g., save success, validation error, network failure). +- `FAILED` → `WA` / `RE` / etc (depending on verdict): ---- + ` WA ` -## 8. Motion, Micro-Interactions & Performance +- `RUNNING`: -- **No external animation libraries** (no Framer Motion, no heavy animation packages). -- Use **lightweight CSS transitions** and Tailwind utilities only: - - `transition-all`, `transition-colors`, `transition-opacity` - - `duration-150` to `duration-300` - - `ease-out`, `ease-in-out` + ` RUNNING ` -- Add reusable animation utilities in `globals.css` (or equivalent), e.g.: - - `.fade-in-soft` - - `.slide-up-soft` - - `.scale-on-hover` +**Problem State:** -- Apply them sparingly: - - Hover effects on cards/buttons - - Subtle entry animations for modals or dropdowns - - Soft highlight when data updates (optional) +- `PUBLISHED`, `DRAFT`, `REVIEW` with matching semantic tokens. -Do NOT over-animate. Prioritize **clarity and snappiness**. +**Contest State:** + +- `RUNNING` → `LIVE` +- `UPCOMING` +- `FINISHED` → `ENDED` --- -## 9. Implementation Rules (Very Important) +## [09] FORMS, MODALS & TOASTS -When you respond: +### 9.1 Forms -1. **Do NOT add unnecessary comments** in the code. - - Only minimal structural comments where absolutely helpful (e.g., `// Main content`, `// Filters`). - - No conversational or explanatory comments inside the TSX. +Multi-step for complex flows (problem authoring, contest creation): -2. **Do NOT change data contracts**: - - Keep props, hooks, and API calls consistent with the existing page unless explicitly required. - - You’re redesigning the UI, not redefining backend contracts. +- Show progress: `[01]`, `[02]`, `[03]`. +- Use clear section breaks. -3. **TypeScript discipline**: - - Avoid `any`. - - Type component props properly. +Input pattern: -4. **Lint & Build Discipline**: - - Assume you will run: - - `npm run lint` - - `npm run build` +```tsx +
+ {" "} + {" "} + {" "} +

Helper text

+
+``` - - Your code must be valid, type-safe, and buildable. - - If you introduce patterns that would cause type/lint errors, fix them before finalizing your answer. +### 9.2 Modals (Dialog) -5. **Support dark & light mode** in all new styles: - - Use Tailwind `dark:` variants consistently. - - Ensure contrast ratios are readable. +```tsx + + {" "} + + {" "} + + {" "} + MODAL TITLE {" "} + + Helper description.{" "} + {" "} + + {/* Content */}{" "} + + +``` -6. **Icons**: - - Never use `lucide-react`. - - Always use `hugeicons-react`, and if unsure, look up the correct icon and import syntax via internet search. +### 9.3 Toasts + +`toast({ title: "Success", description: "Problem created successfully", className: "rounded-none border-2 border-success bg-success/5 font-mono text-sm", +});` --- -## 10. Response Structure & Deliverables +## [10] RESPONSIVE DESIGN -When you answer, always structure your response like this: +Use mobile-first Tailwind patterns: -1. **High-Level UX & Visual Plan (short)** - - 4–8 bullet points explaining the new layout and design decisions. +`className="text-sm sm:text-base md:text-lg lg:text-xl"` -2. **Updated Page Component(s)** - - Full **TSX/JSX** for the redesigned page. - - Include imports. - - Assume Next.js + TypeScript. +Grids: -3. **Nested Components** - - TSX for all **new or redesigned nested components** used by this page (e.g. toolbars, filters, summary cards, side panels, modals). - - Keep them in realistic locations (`components/...` etc.), but you don’t need to show file paths—just clear component definitions. +`className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"` -4. **Styling / Theme Adjustments** - - Any Tailwind or `globals.css` additions (reusable animation utilities, color tokens, etc.). - - Any shadcn theme overrides if needed (can be shown as code snippets). +Page container: -5. **Toasts & State Handling Examples** - - Brief code snippet showing how to trigger success/error/warning/info toasts on this page. - - Show sample handling for loading, empty, and error states in the main UI. +`className="mx-auto max-w-screen-2xl px-6 lg:px-12"` -6. **Short Notes for Future Pages** - - 3–5 bullets explaining how this design ties back into the global OpenSolve system so future pages can follow the same patterns. +Always verify layouts for: + +- Small mobile (~375px) +- Tablet (~768px) +- Desktop (≥ 1280px) --- -## 11. Core Reminder +## [11] ICON SYSTEM — HUGEICONS ONLY + +- **Never** use `lucide-react`. +- Use **hugeicons-react** or a local alias module: -- The current UI is **just a temporary testing version**. -- You are here to create a **premium, modern, bespoke** interface that feels: - - intentional - - cohesive across all OpenSolve surfaces - - tuned for serious power users (competitive programmers, problem setters, contest organizers, admins) + ```tsx + import { Code2Icon, GithubIcon, ArrowRightIcon } from "@/components/icons";` + ``` -- Avoid anything that looks like a copy-paste Tailwind template, a generic AI dashboard, or a portfolio starter. +- Sizes: + - Small: `className="h-4 w-4"` + - Medium: `className="h-5 w-5"` + - Large: `className="h-6 w-6"` + +If you don’t know the icon name, pick a semantically close one. --- -## 12. Input +## [12] IMPLEMENTATION RULES + +### 12.1 TypeScript & Imports + +- TypeScript strict: **no `any`**. +- Prefer types inferred from existing code and types from `@/types`, `@/lib`, etc. +- Import order: + 1. React/Next. + 2. External libraries. + 3. Components (`@/components/...`). + 4. Utilities (`@/lib/...`). + 5. Config (`@/config/...`). + 6. Types. + +### 12.2 File Organization + +Follow this general layout: + +```txt +`components/ + marketing/ # Landing & public pages + problems/ # Problem-related components + contests/ # Contest-related components + workspace/ # Creator workspace components + admin/ # Admin console pieces + staff/ # Staff tools + ui/ # shadcn base components + +config/ + landing.ts # Landing page content + navigation.ts # Nav structure + page-*.ts # Per-page configs when needed + +lib/ + utils.ts # Utilities` +``` + +### 12.3 Config-Driven Content + +Example `config/landing.ts`: + +```tsx +`export const landingConfig = { hero: { badge: "ALPHA v0.1.0", headline: { line1: "CODE", line2: "COMPETE", line3: "CONQUER", + }, description: "Open-source algorithmic practice platform...", + }, stats: [ + { value: "50K+", label: "DEVELOPERS" }, + { value: "1M+", label: "SUBMISSIONS" }, + ], features: { items: [ + { num: "01", title: "...", description: "..." }, + ], + }, +}; export type LandingConfig = typeof landingConfig;`; +``` + +Usage: + +```tsx +import { landingConfig } from "@/config/landing";

{landingConfig.hero.headline.line1}

;` +``` + +--- + +## [13] REDESIGN RESPONSE STRUCTURE + +When asked to redesign a page: + +1. **Brief UX Plan** + - 4–6 bullets describing: + - Layout (sections, grids, split panes). + - Main interactions (filters, tables, command palette, etc.). + - How it applies OpenSolve brutalist/terminal principles. + +2. **Main Page Component (TSX)** + - Full component with imports. + - Uses semantic Tailwind classes and config-driven content. + +3. **Nested Components** + - Any supporting components introduced (e.g. `ContestHeader`, `SubmissionsToolbar`). + - Keep responsibilities focused. + +4. **Config File (if necessary)** + - All non-trivial copy moved into `config/.ts`. -You will now be given **one or more pages** from the OpenSolve codebase. -Apply all rules above. +5. **Notes** + - Any deviations from patterns. + - Integration with existing hooks, tRPC, or domain objects. diff --git a/AUTH_QUICKSTART.md b/AUTH_QUICKSTART.md index f4bbe2a..127ac06 100644 --- a/AUTH_QUICKSTART.md +++ b/AUTH_QUICKSTART.md @@ -236,3 +236,4 @@ Session is auto-loaded in tRPC context. Use appropriate procedure type: - [ ] Review audit logs regularly - [ ] Test 2FA enrollment & recovery flows - [ ] Test email deliverability across providers + diff --git a/app/(auth)/auth/forgot-password/page.tsx b/app/(auth)/auth/forgot-password/page.tsx index d1f0728..466290c 100644 --- a/app/(auth)/auth/forgot-password/page.tsx +++ b/app/(auth)/auth/forgot-password/page.tsx @@ -2,8 +2,12 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import Link from "next/link"; +import { toast } from "sonner"; + import { trpc } from "@/lib/trpc/client"; import { requestPasswordResetSchema, type RequestPasswordResetInput } from "@/lib/validators/auth"; +import { Button } from "@/components/ui/button"; import { Form, FormControl, @@ -11,18 +15,14 @@ import { FormItem, FormLabel, FormMessage, - Button, - Input, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui"; -import { toast } from "sonner"; -import Link from "next/link"; +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ArrowRight, CheckCircle2, Mail } from "@/components/icons"; +import { authConfig } from "@/config/auth"; export default function ForgotPasswordPage() { + const { forgotPassword } = authConfig; + const form = useForm({ resolver: zodResolver(requestPasswordResetSchema), defaultValues: { @@ -45,47 +45,95 @@ export default function ForgotPasswordPage() { }; return ( - - - Reset Password - - Enter your email address and we'll send you a link to reset your password. - - - -
- - ( - - Email - - - - - - )} - /> +
+
+ + + {forgotPassword.badge} + + [03] Password reset +
+ +
+

+ {forgotPassword.headline} +

+

+ {forgotPassword.description} +

+
- - - +
+
+ Security notes +
+
+ {forgotPassword.security.map((note) => ( +
+ + {note} +
+ ))} +
+
+ +
+ + ( + + + Email address + + + + + + + )} + /> + + + + -
- +
+
+ Remember your password? + Back to sign in +
- - +
+ Need an account? + + Create account + + +
+
+
); } + diff --git a/app/(auth)/auth/magic-link/page.tsx b/app/(auth)/auth/magic-link/page.tsx index 38a505f..f44dc85 100644 --- a/app/(auth)/auth/magic-link/page.tsx +++ b/app/(auth)/auth/magic-link/page.tsx @@ -1,24 +1,27 @@ "use client"; -import { useEffect, useState } from "react"; -import { trpc } from "@/lib/trpc/client"; -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui"; import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { CheckCircle2, XCircle, Loader2 } from "@/components/icons"; + +import { AlertTriangle, ArrowRight, CheckCircle2, Loader2, Sparkles } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { authConfig } from "@/config/auth"; +import { trpc } from "@/lib/trpc/client"; export default function MagicLinkPage() { const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams?.get("token") || ""; + const { magicLink } = authConfig; + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); const verifyMutation = trpc.auth.verifyMagicLink.useMutation({ onSuccess: () => { setStatus("success"); toast.success("Signed in successfully"); - // Slight delay so user sees success state setTimeout(() => router.push("/dashboard"), 500); }, onError: (error) => { @@ -38,51 +41,159 @@ export default function MagicLinkPage() { if (status === "loading") { return ( - - - - - Signing You In - - Validating your magic link… - - +
+
+ + + {magicLink.badge} + + [08] Processing +
+ +
+

+ {magicLink.loading.headline} +

+

+ {magicLink.loading.description} +

+
+ +
+
+
+
+ +
+

Validating magic link...

+
+
+ +
+
+ Security check +
+

+ We're verifying your magic link token and establishing a secure session. This may + take a few seconds. +

+
+
); } if (status === "success") { return ( - - - - - Signed In - - You’re being redirected to your dashboard. - - - - - +
+
+ + + Authenticated + + [09] Success +
+ +
+

+ {magicLink.success.headline} +

+

+ {magicLink.success.description} +

+
+ +
+
+
+
+ +
+

Redirecting to dashboard...

+
+
+ + + +
+ + Or{" "} + + +
+
); } return ( - - - - - Link Invalid or Expired - - This sign-in link is invalid or has expired. - - - - - +
+
+ + + Failed + + [10] Error +
+ +
+

+ {magicLink.error.headline} +

+

+ {magicLink.error.description} +

+
+ +
+
+ Common causes +
+
+

• Link has already been used (single-use only)

+

• Link expired (15 minute validity)

+

• Token was tampered with or corrupted

+

• Network error during validation

+
+
+ + + +
+
+ Need a new link? + +
+
+ Use password instead? + +
+
+
); } diff --git a/app/(auth)/auth/reset-password/page.tsx b/app/(auth)/auth/reset-password/page.tsx index 505f7a9..d30cdc9 100644 --- a/app/(auth)/auth/reset-password/page.tsx +++ b/app/(auth)/auth/reset-password/page.tsx @@ -2,8 +2,12 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter, useSearchParams } from "next/navigation"; +import { toast } from "sonner"; + import { trpc } from "@/lib/trpc/client"; import { resetPasswordSchema, type ResetPasswordInput } from "@/lib/validators/auth"; +import { Button } from "@/components/ui/button"; import { Form, FormControl, @@ -11,22 +15,18 @@ import { FormItem, FormLabel, FormMessage, - Button, - Input, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui"; -import { toast } from "sonner"; -import { useRouter, useSearchParams } from "next/navigation"; +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ArrowRight, CheckCircle2, ShieldCheck, AlertTriangle } from "@/components/icons"; +import { authConfig } from "@/config/auth"; export default function ResetPasswordPage() { const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams?.get("token") || ""; + const { resetPassword } = authConfig; + const form = useForm({ resolver: zodResolver(resetPasswordSchema), defaultValues: { @@ -51,57 +51,125 @@ export default function ResetPasswordPage() { if (!token) { return ( - - - Invalid Link - This password reset link is invalid or has expired. - - - - - +
+
+ + + Invalid token + +
+ +
+

+ {resetPassword.invalid.headline} +

+

+ {resetPassword.invalid.description} +

+
+ + + +
+ + Or{" "} + + +
+
); } return ( - - - Set New Password - Enter your new password below. - - -
- - ( - - New Password - - - - -

- At least 8 characters with uppercase, lowercase, and numbers -

-
- )} - /> - - - - -
-
+
+
+ + + {resetPassword.badge} + + [04] New password +
+ +
+

+ {resetPassword.headline} +

+

+ {resetPassword.description} +

+
+ +
+
+ Password requirements +
+
+ {resetPassword.requirements.map((requirement) => ( +
+ + {requirement} +
+ ))} +
+
+ +
+ + ( + + + New password + + + + + + + )} + /> + + + + + +
+
+ Password reset? + +
+
+
); } + diff --git a/app/(auth)/auth/verify-email/page.tsx b/app/(auth)/auth/verify-email/page.tsx index 59f6388..acd9aa5 100644 --- a/app/(auth)/auth/verify-email/page.tsx +++ b/app/(auth)/auth/verify-email/page.tsx @@ -1,17 +1,21 @@ "use client"; -import { useEffect, useState } from "react"; -import { trpc } from "@/lib/trpc/client"; -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui"; import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { CheckCircle2, XCircle, Loader2 } from "@/components/icons"; + +import { AlertTriangle, ArrowRight, CheckCircle2, Loader2 } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { authConfig } from "@/config/auth"; +import { trpc } from "@/lib/trpc/client"; export default function VerifyEmailPage() { const router = useRouter(); const searchParams = useSearchParams(); const token = searchParams?.get("token") || ""; + const { verifyEmail } = authConfig; + const [status, setStatus] = useState<"loading" | "success" | "error">( token ? "loading" : "error", ); @@ -38,53 +42,140 @@ export default function VerifyEmailPage() { if (status === "loading") { return ( - - - - - Verifying Email - - Please wait while we verify your email address... - - +
+
+ + + {verifyEmail.badge} + + [05] Processing +
+ +
+

+ {verifyEmail.loading.headline} +

+

+ {verifyEmail.loading.description} +

+
+ +
+
+
+

Validating token...

+
+
+
); } if (status === "success") { return ( - - - - - Email Verified - - - Your email has been successfully verified. You can now access all features. - - - - - - +
+
+ + + Verified + + [06] Complete +
+ +
+

+ {verifyEmail.success.headline} +

+

+ {verifyEmail.success.description} +

+
+ +
+
+ Unlocked features +
+
+ {verifyEmail.success.features.map((feature) => ( +
+ + {feature} +
+ ))} +
+
+ + + +
+ + Or{" "} + + +
+
); } return ( - - - - - Verification Failed - - This verification link is invalid or has expired. - - - - - +
+
+ + + Failed + + [07] Error +
+ +
+

+ {verifyEmail.error.headline} +

+

+ {verifyEmail.error.description} +

+
+ +
+
+ Next steps +
+
+

1. Sign in to your account

+

2. Navigate to Settings → Account

+

3. Request a new verification email

+
+
+ + + +
+
+ Need help? + +
+
+
); } + diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index ca23b1a..acf5919 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,8 +1,12 @@ -import { getSession } from "@/lib/auth/session"; -import { Code2, Sparkles } from "@/components/icons"; import Link from "next/link"; -import { redirect } from "next/navigation"; import type { PropsWithChildren } from "react"; +import { redirect } from "next/navigation"; + +import { CommandPalette } from "@/components/marketing/command-palette"; +import { ThemeToggle } from "@/components/ui/theme-toggle"; +import { CheckCircle2, Code2, Sparkles } from "@/components/icons"; +import { authConfig } from "@/config/auth"; +import { getSession } from "@/lib/auth/session"; export const dynamic = "force-dynamic"; @@ -12,72 +16,75 @@ export default async function AuthLayout({ children }: PropsWithChildren) { redirect("/dashboard"); } - return ( -
-
-
- -
- -
- OpenSolve - -
+ const { layout } = authConfig; -
-
-
- - For developers, by developers -
-

- Master algorithms through deliberate practice -

-

- Join a community of engineers solving problems, competing in contests, and sharpening - their craft every day. -

-
- -
-
-
12,000+
-
Active developers
-
-
-
500+
-
Curated problems
-
-
-
50+
-
Weekly contests
+ return ( + <> + +
+
+
+ +
+ +
+
+ + OpenSolve + + + {layout.badge} + +
+ +
+ + ⌘K + +
-
+ -
© 2025 OpenSolve. Built with care.
-
- -
-
-
- -
- +
+
+
+

+ {layout.headline} +

+

{layout.tagline}

+
+
+ [00] Platform pulse +
+
+ {layout.stats.map((stat) => ( +
+ + {stat.label} + + {stat.value} +
+ ))} +
+
+ + Authentication is audited and contest-safe by design. +
+
+ - {children} -
+
{children}
+
+
-
+ ); } diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index fbd7a04..476a8a6 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -1,54 +1,54 @@ -import { SignInForm } from "@/components/auth/sign-in-form"; import Link from "next/link"; +import { SignInForm } from "@/components/auth/sign-in-form"; +import { ArrowRight } from "@/components/icons"; +import { authConfig } from "@/config/auth"; + export default function SignInPage() { + const { signIn } = authConfig; + return ( -
-
-

Welcome back

-

Sign in to continue your learning journey

+
+
+ [02] Sign in + + + Auth required +
- - -
-
-
- - or - -
+
+
+ Need a reset? + + {signIn.links.forgot.label} +
- -
+
+ New here? - Forgot your password? + {signIn.links.signup.label} + -
- New to OpenSolve?{" "} - - Create an account - -
+
+
+ By signing in, you agree to our{" "} + + {signIn.links.terms.label} + {" "} + and{" "} + + {signIn.links.privacy.label} + + .
- -
- By signing in, you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - -
-
+
); } diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index dd41019..14ef8b9 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -1,46 +1,45 @@ -import { SignUpForm } from "@/components/auth/sign-up-form"; import Link from "next/link"; +import { SignUpForm } from "@/components/auth/sign-up-form"; +import { ArrowRight } from "@/components/icons"; +import { authConfig } from "@/config/auth"; + export default function SignUpPage() { + const { signUp } = authConfig; + return ( -
-
-

Create your account

-

Start solving problems and competing today

+
+
+ [02] Create your profile + + + Two-step onboarding +
- - -
-
-
- - or - -
-
- -
- Already have an account?{" "} +
+
+ Already registered? - Sign in + {signUp.links.signin.label} +
+
+ By creating an account, you agree to our{" "} + + {signUp.links.terms.label} + {" "} + and{" "} + + {signUp.links.privacy.label} + + . +
- -
- By creating an account, you agree to our{" "} - - Terms - {" "} - and{" "} - - Privacy Policy - -
-
+
); } diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 147b78d..1da6789 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,111 +1,338 @@ -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - ThemeToggle, -} from "@/components/ui"; +import { ArrowRight, Code2, Github } from "@/components/icons"; +import { CommandPalette } from "@/components/marketing/command-palette"; +import { SplitEditor } from "@/components/marketing/split-editor"; +import { ThemeToggleLanding } from "@/components/marketing/theme-toggle-landing"; +import { Button } from "@/components/ui"; +import { landingConfig } from "@/config/landing"; import { siteConfig } from "@/config/site"; import Link from "next/link"; -import { Code2, Sparkles } from "@/components/icons"; export const revalidate = 21600; export default function Home() { return ( -
- {/* Background gradients */} -
-
-
- -
-
-
- -
-
- - {siteConfig.name} - - - Pre-alpha - + <> + + +
+
- -
- -
-
- - - Built for the community - -

- Open-source practice platform for{" "} - - interviews - - , contests, and teams. -

-

{siteConfig.description}

-
- - + +
+
+
+
+ + {landingConfig.hero.badge} +
+ +

+ {landingConfig.hero.headline.line1} +
+ {landingConfig.hero.headline.line2} +
+ + {landingConfig.hero.headline.line3} + +

+ +

+ {landingConfig.hero.description} +

+ +
+ + +
+ +
+ {landingConfig.stats.map((stat) => ( +
+
+ {stat.value} +
+
{stat.label}
+
+ ))} +
+
+ +
+
+ +
+
+
+ +
+
+
+
+ [01] WHY OPENSOLVE +
+

+ {landingConfig.features.title} +

+
+
+

+ {landingConfig.features.subtitle} +

+
+
+ +
+ {landingConfig.features.items.map((feature) => ( +
+
+
+
+ [{feature.num}] +
+

+ {feature.title} +

+

+ {feature.description} +

+
+
+ ))} +
+
+ +
+
+
[02] THE STACK
+

+ {landingConfig.techStack.title} +

+
+ +
+ {landingConfig.techStack.technologies.map((tech) => ( +
+
+ + {tech} + +
+ ))} +
+ +
+

+ {landingConfig.techStack.footer} +

+
+
+ +
+
+
+
+ +
+
+ [03] GET STARTED +
+

+ {landingConfig.cta.title} +

+

+ {landingConfig.cta.subtitle} +

+ +
+ + +
+ +
+ {landingConfig.cta.benefits.map((benefit) => ( +
+ {benefit.icon} + {benefit.label} +
+ ))} +
+
+
+
+
+ +
+
+
+
+
+
+ +
+ {siteConfig.name} +
+

+ Open-source algorithmic practice platform +

+
+ +
+
PLATFORM
+ +
+ +
+
RESOURCES
+ +
+ +
+
LEGAL
+ +
+
+ +
+ © {new Date().getFullYear()} {siteConfig.name} · Built by developers, for developers +
-
- -
- - - Self-host ready - - -

Next.js + PostgreSQL + Prisma foundation with strict TypeScript.

-

Docker-first workflow and seeds prepared for future automation.

-
-
- - - Extensible core - - -

React Query providers, UI primitives, and modular layouts pre-wired.

-

Plug in judging services, auth strategies, and AI helpers incrementally.

-
-
-
-
-
+ +
+ ); } diff --git a/app/(platform)/dashboard/page.tsx b/app/(platform)/dashboard/page.tsx index be61fdb..8034f63 100644 --- a/app/(platform)/dashboard/page.tsx +++ b/app/(platform)/dashboard/page.tsx @@ -1,31 +1,44 @@ +import type { ComponentProps, ComponentType } from "react"; +import Link from "next/link"; +import { format, formatDistanceToNow } from "date-fns"; +import type { inferRouterOutputs } from "@trpc/server"; +import { ProblemProposalStatus } from "@prisma/client"; +import { + ArrowRight05Icon, + Award02Icon, + Calendar02Icon, + Megaphone01Icon, + SparklesIcon, + Target01Icon, +} from "hugeicons-react"; + +import { dashboardConfig } from "@/config/dashboard"; import { RecentSubmissionsTable, type RecentSubmissionRow, } from "@/components/dashboard/recent-submissions-table"; +import { + SubmissionOutcomeChart, + type SubmissionOutcomeDatum, +} from "@/components/dashboard/submission-outcome-chart"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { getSession } from "@/lib/auth/session"; import type { AppRouter } from "@/lib/trpc/router"; import { createTRPCCaller } from "@/lib/trpc/server/caller"; import { cn } from "@/lib/utils"; -import { ProblemProposalStatus } from "@prisma/client"; -import type { inferRouterOutputs } from "@trpc/server"; -import { formatDistanceToNow } from "date-fns"; -import { - ArrowRight05Icon, - Award02Icon, - Calendar02Icon, - CodeSquareIcon, - Megaphone01Icon, - SparklesIcon, - Target01Icon, -} from "hugeicons-react"; -import Link from "next/link"; type RouterOutputs = inferRouterOutputs; type SubmissionSnapshot = RouterOutputs["submissions"]["listMine"]; type ProposalEntry = RouterOutputs["proposals"]["listMine"][number]; +type ContestOverviewResponse = RouterOutputs["contests"]["overview"]; +type ProposalStatKey = (typeof dashboardConfig.sections.activity.proposals.stats)[number]["key"]; +type ProposalStats = Record; +type SubmissionEntry = SubmissionSnapshot["items"][number]; +type ContestPreview = + | ContestOverviewResponse["upcoming"][number] + | ContestOverviewResponse["live"][number] + | NonNullable; const ACCEPTED_CODES = new Set(["AC", "MANUAL_ACCEPTED"]); @@ -74,360 +87,452 @@ export default async function DashboardPage() { })); const proposalStats = summarizeProposals(proposals); - - const metrics = [ + const heroHighlights = [ { - label: "Solved problems", - value: submissionSummary.solvedProblems, - meta: `${submissionSummary.acceptedAttempts} accepted`, - icon: Target01Icon, - accent: "text-emerald-600 dark:text-emerald-300", + label: dashboardConfig.hero.highlights.solved, + value: submissionSummary.solvedProblems.toString(), + meta: `${submissionSummary.acceptedAttempts} ${dashboardConfig.metrics.solved.metaSuffix}`, }, { - label: "Acceptance rate", + label: dashboardConfig.hero.highlights.acceptance, value: `${acceptanceRate}%`, - meta: `${submissionSummary.totalAttempts} attempts`, - icon: SparklesIcon, - accent: "text-blue-600 dark:text-blue-300", - }, - { - label: "Manual reviews", - value: submissionSummary.manualPending, - meta: submissionSummary.manualPending > 0 ? "Awaiting staff action" : "All clear", - icon: Megaphone01Icon, - accent: "text-amber-600 dark:text-amber-300", + meta: `${submissionSummary.totalAttempts} ${dashboardConfig.metrics.acceptance.metaSuffix}`, }, { - label: "Fastest runtime", - value: submissionSummary.fastestRuntimeMs ? `${submissionSummary.fastestRuntimeMs} ms` : "—", - meta: submissionSummary.bestMemoryKb - ? `${submissionSummary.bestMemoryKb} kb memory` - : "No benchmark", - icon: ArrowRight05Icon, - accent: "text-purple-600 dark:text-purple-300", + label: dashboardConfig.hero.highlights.contest, + value: nextContest ? nextContest.name : dashboardConfig.emptyStates.contests.title, + meta: nextContest + ? formatDistanceToNow(new Date(nextContest.startsAt), { addSuffix: true }) + : dashboardConfig.hero.highlights.contestFallback, }, ]; + const operatorName = session?.user.name ?? session?.user.handle ?? "solver"; - const quickActions = [ + const metrics = [ { - label: "Browse problems", - href: "/problems", - icon: CodeSquareIcon, - description: "Filter by tags, difficulty, and editorial access.", + label: dashboardConfig.metrics.solved.label, + value: submissionSummary.solvedProblems.toString(), + meta: `${submissionSummary.acceptedAttempts} ${dashboardConfig.metrics.solved.metaSuffix}`, + icon: Target01Icon, + accent: "text-success", }, { - label: "Review submissions", - href: "/submissions", + label: dashboardConfig.metrics.acceptance.label, + value: `${acceptanceRate}%`, + meta: `${submissionSummary.totalAttempts} ${dashboardConfig.metrics.acceptance.metaSuffix}`, icon: SparklesIcon, - description: "Inspect verdicts and rerun local tests.", + accent: "text-info", }, { - label: "Track proposals", - href: "/proposals", + label: dashboardConfig.metrics.manual.label, + value: submissionSummary.manualPending.toString(), + meta: + submissionSummary.manualPending > 0 + ? dashboardConfig.metrics.manual.pending + : dashboardConfig.metrics.manual.idle, icon: Megaphone01Icon, - description: "Draft new problems or follow up on feedback.", + accent: "text-warning", + }, + { + label: dashboardConfig.metrics.performance.label, + value: submissionSummary.fastestRuntimeMs ? `${submissionSummary.fastestRuntimeMs} ms` : "—", + meta: submissionSummary.bestMemoryKb + ? `${dashboardConfig.metrics.performance.memoryLabel}: ${submissionSummary.bestMemoryKb} kb` + : dashboardConfig.metrics.performance.fallback, + icon: ArrowRight05Icon, + accent: "text-primary", }, ]; + const submissionOutcomeSeries = buildSubmissionOutcomeSeries(submissionSnapshot.items); return ( -
-
-
-
-
-

- Mission Control -

-

- {session?.user.name ?? session?.user.handle ?? "Solver"}, get ready for the next - solve. -

-

- Monitor your submissions, upcoming contests, and proposals without leaving this - cockpit. -

-
-
- {quickActions.map((action) => ( - - -

{action.label}

-

{action.description}

- - ))} -
-
-
-

- Last submission -

-

- {submissionSummary.lastSubmissionAt - ? formatDistanceToNow(submissionSummary.lastSubmissionAt, { addSuffix: true }) - : "No history yet"} +

+
+
+
+

+ {dashboardConfig.hero.marker}

-

- Keep the streak alive by solving any problem today. -

-
-
- Attempts - {submissionSummary.totalAttempts} -
-
-
-
-
- Acceptance - {acceptanceRate}% -
+
+ + {dashboardConfig.hero.badge}
-
+

+ {dashboardConfig.hero.headline.line1}
+ {dashboardConfig.hero.headline.line2}
+ + {dashboardConfig.hero.headline.line3} + +

+

{dashboardConfig.hero.description}

+

+ Operator: {operatorName} +

+
+ + +
+
+ {heroHighlights.map((highlight) => ( + + ))} +
+
+ {dashboardConfig.quickActions.map((action) => ( + + ))}
+
+

+ {dashboardConfig.hero.summaryLabel} +

+

+ {submissionSummary.lastSubmissionAt + ? formatDistanceToNow(submissionSummary.lastSubmissionAt, { addSuffix: true }) + : dashboardConfig.hero.historyFallback} +

+

{dashboardConfig.hero.summaryHelper}

+
+
+
{dashboardConfig.hero.attemptsLabel}
+
{submissionSummary.totalAttempts}
+
+
+
+
+
+
{dashboardConfig.hero.acceptanceLabel}
+
{acceptanceRate}%
+
+
+
-
- {metrics.map((metric) => ( - - -
-

{metric.label}

-

{metric.value}

-

{metric.meta}

+
+ +
+ {metrics.map((metric) => ( + + ))} +
+
+ +
+ +
+
+
+
+

+ {dashboardConfig.sections.telemetry.chart.title} +

+ + {submissionOutcomeSeries.length} DAYS +
-
- +

+ {dashboardConfig.sections.telemetry.chart.description} +

+
+
+ {submissionOutcomeSeries.length > 0 ? ( + + ) : ( + + )} +
+
+
+
+
+

+ {dashboardConfig.sections.backlog.title} +

+

+ {dashboardConfig.sections.backlog.description} +

- - - ))} +
+
+ {practiceDeck.length > 0 ? ( + practiceDeck.map((problem) => ( +
+
+ + {problem.title} + + + {problem.difficulty?.toLowerCase() ?? + dashboardConfig.sections.backlog.unknownDifficulty} + +
+

+ {dashboardConfig.sections.backlog.acceptanceLabel}{" "} + {problem.acceptanceRate !== null + ? `${Math.round(problem.acceptanceRate * 100)}%` + : "—"} +

+
+ {problem.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} +
+
+ )) + ) : ( + + )} +
+
+
-
- - -
+
+ +
+
+
- Recent activity - Latest submissions across contests and practice. +

+ {dashboardConfig.sections.activity.table.title} +

+

+ {dashboardConfig.sections.activity.table.description} +

-
- - - {submissionRows.length > 0 ? ( - - ) : ( - - )} - - - - - - Proposal pipeline - Drafts and submissions you are currently tracking. - - -
- {proposalStats.map((status) => ( -
-

{status.value}

-

{status.label}

+
+ {submissionRows.length > 0 ? ( + + ) : ( + + )} +
+
+
+
+
+

+ {dashboardConfig.sections.activity.proposals.title} +

+

+ {dashboardConfig.sections.activity.proposals.description} +

+
+ + {proposals.length} {dashboardConfig.sections.activity.proposals.statusLabel} + +
+
+ {dashboardConfig.sections.activity.proposals.stats.map((stat) => ( +
+

{proposalStats[stat.key]}

+

{stat.label}

))}
-
+
{proposals.slice(0, 4).map((proposal) => ( -
{proposal.title} - {proposal.status.replace("_", " ").toLowerCase()} + {proposal.status.replace("_", " ").toUpperCase()}

- Intended difficulty: {proposal.intendedDifficulty} + {dashboardConfig.sections.activity.proposals.intendedDifficultyLabel}:{" "} + {proposal.intendedDifficulty}

-
+ ))} {proposals.length === 0 ? ( - + ) : null}
- - - +
+
-
- - -
- Contest radar - Preview windows, registrations, and rating impact. -
- - - {contestOverview.live.length} live · {contestOverview.upcoming.length} upcoming +
+ +
+
+ +

+ {dashboardConfig.sections.contests.featuredLabel} +

+ + {contestOverview.live.length} LIVE · {contestOverview.upcoming.length} UPCOMING - - +
+
{nextContest ? ( -
-

Featured contest

-

{nextContest.name}

-

{nextContest.description}

-
- - - -
-
- - {nextContest.type.toLowerCase()} - - - {nextContest.state.toLowerCase()} - - {nextContest.isRated ? ( - - rated - - ) : null} -
-
- - -
-
+ ) : ( - + )} -
- {contestOverview.upcoming.slice(0, 4).map((contest) => ( -
-

{contest.name}

-

- {formatDistanceToNow(new Date(contest.startsAt), { addSuffix: true })} -

-
- {contest.problemCount} problems - {contest.registrationCount} teams -
+
+
+

{dashboardConfig.sections.contests.upcomingLabel}

+ {contestOverview.upcoming.length} events +
+ {contestOverview.upcoming.length > 0 ? ( +
+ {contestOverview.upcoming.slice(0, 4).map((contest) => ( +
+

{contest.name}

+

+ {formatDistanceToNow(new Date(contest.startsAt), { addSuffix: true })} +

+
+ + {contest.problemCount}{" "} + {dashboardConfig.sections.contests.stats.problems.toLowerCase()} + + + {contest.registrationCount}{" "} + {dashboardConfig.sections.contests.upcomingMeta.teams} + +
+
+ ))}
- ))} -
- - - - - - Focus backlog - Fresh problems to bookmark for this week’s drills. - - - {practiceDeck.length > 0 ? ( - practiceDeck.map((problem) => ( -
-
- - {problem.title} - - - {problem.difficulty?.toLowerCase() ?? "unknown"} - -
-

- Acceptance{" "} - {problem.acceptanceRate !== null - ? `${Math.round(problem.acceptanceRate * 100)}%` - : "—"} -

-
- {problem.tags.slice(0, 3).map((tag) => ( - - {tag.name} - - ))} -
+ ) : ( +
+
- )) - ) : ( - - )} - - + )} +
+
+
); } +function buildSubmissionOutcomeSeries( + items: SubmissionSnapshot["items"], +): SubmissionOutcomeDatum[] { + const uniqueRuns = new Map(); + for (const entry of items) { + const key = `${entry.problemId}:${entry.codeHash ?? entry.id}`; + if (!uniqueRuns.has(key)) { + uniqueRuns.set(key, entry); + } + } + const buckets = new Map< + number, + { + date: Date; + accepted: number; + failed: number; + } + >(); + + for (const entry of uniqueRuns.values()) { + const created = new Date(entry.createdAt); + const bucketKey = new Date( + created.getFullYear(), + created.getMonth(), + created.getDate(), + ).getTime(); + const bucket = buckets.get(bucketKey) ?? { + date: new Date(created.getFullYear(), created.getMonth(), created.getDate()), + accepted: 0, + failed: 0, + }; + if (!buckets.has(bucketKey)) { + buckets.set(bucketKey, bucket); + } + if (entry.verdictCode && ACCEPTED_CODES.has(entry.verdictCode)) { + bucket.accepted += 1; + } else { + bucket.failed += 1; + } + } + + return Array.from(buckets.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .slice(-10) + .map( + (bucket): SubmissionOutcomeDatum => ({ + dateLabel: format(bucket.date, "MMM d"), + accepted: bucket.accepted, + failed: bucket.failed, + }), + ); +} + function ensureSubmissionSummary(snapshot: SubmissionSnapshot) { if (snapshot.summary) { return snapshot.summary; @@ -456,7 +561,7 @@ function ensureSubmissionSummary(snapshot: SubmissionSnapshot) { }; } -function summarizeProposals(proposals: ProposalEntry[]) { +function summarizeProposals(proposals: ProposalEntry[]): ProposalStats { const statusOrder: ProblemProposalStatus[] = [ ProblemProposalStatus.SUBMITTED, ProblemProposalStatus.PRESCREEN, @@ -475,53 +580,192 @@ function summarizeProposals(proposals: ProposalEntry[]) { counts[proposal.status] = (counts[proposal.status] ?? 0) + 1; } - return [ - { label: "Submitted", value: counts.SUBMITTED }, - { label: "In review", value: counts.IN_REVIEW + counts.PRESCREEN }, - { label: "Accepted", value: counts.ACCEPTED }, - ]; + return { + submitted: counts.SUBMITTED, + review: counts.IN_REVIEW + counts.PRESCREEN, + accepted: counts.ACCEPTED, + }; } function statusBadgeClass(status: ProblemProposalStatus) { switch (status) { - case "SUBMITTED": - return "bg-blue-500/10 text-blue-600 dark:text-blue-300"; - case "PRESCREEN": - case "IN_REVIEW": - return "bg-amber-500/10 text-amber-600 dark:text-amber-300"; - case "CHANGES_REQUESTED": - return "bg-purple-500/10 text-purple-600 dark:text-purple-300"; - case "ACCEPTED": - return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-300"; - case "REJECTED": - return "bg-rose-500/10 text-rose-600 dark:text-rose-300"; + case ProblemProposalStatus.SUBMITTED: + return "border-info/30 bg-info/10 text-info"; + case ProblemProposalStatus.PRESCREEN: + case ProblemProposalStatus.IN_REVIEW: + return "border-warning/30 bg-warning/10 text-warning"; + case ProblemProposalStatus.CHANGES_REQUESTED: + return "border-primary/30 bg-primary/10 text-primary"; + case ProblemProposalStatus.ACCEPTED: + return "border-success/30 bg-success/10 text-success"; + case ProblemProposalStatus.REJECTED: + return "border-destructive/30 bg-destructive/10 text-destructive"; } } -function formatDate(date: Date | string) { - const instance = typeof date === "string" ? new Date(date) : date; - return instance.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); +function SectionHeader({ + marker, + title, + description, +}: { + marker: string; + title: string; + description?: string; +}) { + return ( +
+

{marker}

+
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+
+ ); +} + +type IconComponent = ComponentType>; + +function QuickActionLink({ + icon: Icon, + label, + description, + href, +}: { + icon: IconComponent; + label: string; + description: string; + href: string; +}) { + return ( + +
+ {label} + +
+
+ +

{description}

+
+ + ); } -function ContestStat({ +function MetricCard({ label, value, - muted, + meta, + icon: Icon, + accent, }: { label: string; - value: string | number; - muted?: string; + value: string; + meta: string; + icon: IconComponent; + accent?: string; }) { return ( -
+
+
+

{label}

+
+ +
+
+

{value}

+

{meta}

+
+ ); +} + +function HighlightTile({ label, value, meta }: { label: string; value: string; meta: string }) { + return ( +

{label}

-

{value}

- {muted ?

{muted}

: null} +

{value}

+

{meta}

+
+ ); +} + +function FeaturedContestCard({ contest }: { contest: ContestPreview }) { + const startsAt = new Date(contest.startsAt); + + return ( +
+
+
+

+ {dashboardConfig.sections.contests.featuredLabel} +

+

{contest.name}

+

{contest.description}

+
+
+ + {dashboardConfig.sections.contests.badges.type}: {contest.type} + + + {dashboardConfig.sections.contests.badges.state}: {contest.state} + + {contest.isRated ? ( + {dashboardConfig.sections.contests.badges.rated} + ) : null} +
+
+
+ + + +
+
+
+ {contest.viewerRegistration + ? dashboardConfig.sections.contests.registrationMeta.registered + : dashboardConfig.sections.contests.registrationMeta.notRegistered} +
+
+ + +
+
+
+ ); +} + +function ContestFact({ label, value, meta }: { label: string; value: string; meta?: string }) { + return ( +
+

{label}

+

{value}

+ {meta ?

{meta}

: null}
); } @@ -531,25 +775,18 @@ function EmptyState({ description, actionHref, actionLabel, - compact = false, }: { title: string; description: string; actionHref: string; actionLabel: string; - compact?: boolean; }) { return ( -
- -

{title}

-

{description}

-
diff --git a/app/(platform)/problems/[slug]/submissions/page.tsx b/app/(platform)/problems/[slug]/submissions/page.tsx index 6b6ec9b..9326195 100644 --- a/app/(platform)/problems/[slug]/submissions/page.tsx +++ b/app/(platform)/problems/[slug]/submissions/page.tsx @@ -1,9 +1,10 @@ import { SubmissionsClient } from "@/components/submissions/submissions-client"; +import { submissionsConfig } from "@/config/submissions"; +import { getCachedProblemDetail } from "@/lib/cache/problems"; import { buildSubmissionListInputFromParams } from "@/lib/submissions/filter-utils"; import { loadSubmissionSearchParams } from "@/lib/submissions/search-params"; import { createTRPCCaller } from "@/lib/trpc/server/caller"; import { notFound } from "next/navigation"; -import { getCachedProblemDetail } from "@/lib/cache/problems"; export default async function ProblemSubmissionsPage({ params, @@ -38,11 +39,22 @@ export default async function ProblemSubmissionsPage({ } return ( -
-
-

Problem

-

{problem.title}

-
+
+
+

+ {submissionsConfig.problem.marker} +

+

+ {submissionsConfig.problem.titlePrefix} +

+

+ {submissionsConfig.problem.description} +

+
+

Problem

+

{problem.title}

+
+
-
- - -
-
- - +
+ +
+ +
); @@ -138,38 +138,87 @@ export default function AccountSettingsPage() { return null; } - return ( -
-
-

Account Settings

-

- Manage your account details and security preferences -

-
+ const metrics = [ + { + label: settingsConfig.account.stats.verification, + value: session.user.emailVerified + ? settingsConfig.account.info.verified + : settingsConfig.account.info.unverified, + }, + { + label: settingsConfig.account.stats.sessions, + value: `${sessions?.length ?? 0}`, + }, + { + label: settingsConfig.account.stats.twoFactor, + value: session.user.twoFactorEnabled + ? settingsConfig.account.info.twoFactorEnabled + : settingsConfig.account.info.twoFactorDisabled, + }, + ]; -
-
-

Account Information

-

Your core account details

+ return ( +
+
+
+
+
+ {settingsConfig.account.marker} + + + {settingsConfig.account.badge} + +
+

+ {settingsConfig.account.headline.line1} +
+ {settingsConfig.account.headline.line2} +
+ + {settingsConfig.account.headline.line3} + +

+

+ {settingsConfig.account.description} +

+
+
+ {metrics.map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+
-
-
-
- - Email Address -
-
-

{session.user.email}

+
+ +
+
+ } + label={settingsConfig.account.info.emailLabel} + /> +
+

{session.user.email}

{session.user.emailVerified ? ( - - - Verified + + + {settingsConfig.account.info.verified} ) : ( - - - Not Verified + + + {settingsConfig.account.info.unverified} )}
@@ -179,119 +228,130 @@ export default function AccountSettingsPage() { variant="outline" onClick={() => resendVerification.mutate()} disabled={resendVerification.isPending} - className="mt-2 h-8 rounded-lg text-xs" + className="h-9 rounded-none border-2 border-border px-4 text-xs font-bold uppercase" > - {resendVerification.isPending ? "Sending..." : "Resend Verification"} + {resendVerification.isPending + ? settingsConfig.account.info.resend.concat("...") + : settingsConfig.account.info.resend} ) : null} +

+ {settingsConfig.account.info.emailFootnote} +

-
-
- - Username -
-

@{session.user.handle}

+
+ } + label={settingsConfig.account.info.usernameLabel} + /> +

@{session.user.handle}

+

{settingsConfig.account.info.title}

-
-
- - Account Role -
- +
+ } + label={settingsConfig.account.info.roleLabel} + /> + {session.user.role}
-
-
- - Two-Factor Auth -
-
-

- {session.user.twoFactorEnabled ? "Enabled" : "Disabled"} +

+ } + label={settingsConfig.account.info.twoFactorLabel} + /> +
+

+ {session.user.twoFactorEnabled + ? settingsConfig.account.info.twoFactorEnabled + : settingsConfig.account.info.twoFactorDisabled}

-
- -
-
-
-

Active Sessions

-

- Devices where you're currently signed in -

-
- - {sessions?.length ?? 0} active - -
+
-
- {sessions?.map((sess) => ( -
-
-
- {getDeviceIcon(sess.userAgent)} -
-
-
-

{sess.userAgent || "Unknown Device"}

- {sess.isCurrent ? ( - - Current - - ) : null} +
+ +
+ {sessions && sessions.length > 0 ? ( +
+ {sessions.map((sess) => ( +
+
+
+ {getDeviceIcon(sess.userAgent)} +
+
+
+

+ {sess.userAgent || settingsConfig.account.sessions.deviceUnknown} +

+ {sess.isCurrent ? ( + + {settingsConfig.account.sessions.current} + + ) : null} +
+

+ {sess.ipAddress} · {settingsConfig.account.sessions.lastActive}{" "} + {formatDistanceToNow(new Date(sess.lastUsedAt), { addSuffix: true })} +

+
-

- {sess.ipAddress} · Last active{" "} - {formatDistanceToNow(new Date(sess.lastUsedAt), { addSuffix: true })} -

+ {!sess.isCurrent ? ( + + ) : null}
-
- {!sess.isCurrent ? ( - - ) : null} + ))} +
+ ) : ( +
+ {settingsConfig.account.sessions.empty}
- ))} + )}
-
- -
-
- +
+
+
-

Sign out everywhere

+

+ {settingsConfig.account.sessions.signOutAll.title} +

- Terminate all active sessions on all devices + {settingsConfig.account.sessions.signOutAll.description}

@@ -299,95 +359,133 @@ export default function AccountSettingsPage() { variant="outline" onClick={() => signOutAllMutation.mutate()} disabled={signOutAllMutation.isPending} - className="rounded-xl border-warning/40 text-warning hover:bg-warning/10" + className="h-10 rounded-none border-2 border-warning/60 px-4 text-xs font-bold uppercase text-warning hover:bg-warning/20" > {signOutAllMutation.isPending ? ( - + <> + + {settingsConfig.account.sessions.signOutAll.cta} + ) : ( - + <> + + {settingsConfig.account.sessions.signOutAll.cta} + )} - Sign Out All
-
+
-
-
-
- -
-
-

Danger Zone

+
+
+
+

+ {settingsConfig.account.danger.marker} +

+

+ {settingsConfig.account.danger.title} +

- Irreversible actions. Please proceed with caution. + {settingsConfig.account.danger.description}

+ + + + + + + {settingsConfig.account.danger.deleteTitle} + + {settingsConfig.account.danger.deleteDescription} + + +
+
+

+ {settingsConfig.account.danger.warningTitle} +

+
    + {settingsConfig.account.danger.bullets.map((item) => ( +
  • {item}
  • + ))} +
+
+
+ + setDeletePassword(e.target.value)} + placeholder={settingsConfig.account.danger.confirmHelper} + /> +
+
+ + + Cancel + + { + if (deletePassword) { + deleteAccountMutation.mutate({ password: deletePassword }); + } + }} + disabled={!deletePassword || deleteAccountMutation.isPending} + className="rounded-none border-2 border-destructive bg-destructive px-4 py-2 font-mono text-sm font-bold uppercase text-destructive-foreground hover:bg-destructive/90" + > + {deleteAccountMutation.isPending ? ( + <> + + {settingsConfig.account.danger.confirmCta} + + ) : ( + settingsConfig.account.danger.confirmCta + )} + + +
+
+
+
+ ); +} -
- - - - - - - - Delete Account Permanently? - - This action cannot be undone. This will permanently delete your account and remove - all your data from our servers including submissions, proposals, and progress. - - -
-
-

Warning:

-
    -
  • All your submissions will be permanently deleted
  • -
  • Your profile and statistics will be removed
  • -
  • This action cannot be reversed
  • -
-
-
- - setDeletePassword(e.target.value)} - placeholder="Your password" - className="mt-2 rounded-xl" - /> -
-
- - Cancel - { - if (deletePassword) { - deleteAccountMutation.mutate({ password: deletePassword }); - } - }} - disabled={!deletePassword || deleteAccountMutation.isPending} - className="rounded-xl bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deleteAccountMutation.isPending ? ( - <> - - Deleting... - - ) : ( - "Delete Account" - )} - - -
-
+function SectionHeader({ + marker, + title, + description, +}: { + marker: string; + title: string; + description?: string; +}) { + return ( +
+

{marker}

+
+

{title}

+ {description ? ( +

{description}

+ ) : null}
); } + +function LabelRow({ icon, label }: { icon: ReactNode; label: string }) { + return ( +
+ {icon} + {label} +
+ ); +} + diff --git a/app/(platform)/settings/profile/page.tsx b/app/(platform)/settings/profile/page.tsx index 22c6493..7eb2626 100644 --- a/app/(platform)/settings/profile/page.tsx +++ b/app/(platform)/settings/profile/page.tsx @@ -1,4 +1,9 @@ "use client"; +import { useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { Avatar, @@ -15,16 +20,12 @@ import { Skeleton, Textarea, } from "@/components/ui"; +import { CheckCircle2, Loader2, Upload } from "@/components/icons"; +import { settingsConfig } from "@/config/settings"; import { invalidateAuthSession } from "@/lib/react-query/invalidation"; import { sessionQueryOptions } from "@/lib/react-query/policies"; import { trpc } from "@/lib/trpc/client"; import { updateProfileSchema, type UpdateProfileInput } from "@/lib/validators/auth"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQueryClient } from "@tanstack/react-query"; -import { CheckCircle2, Loader2, Upload } from "@/components/icons"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; export default function ProfileSettingsPage() { const queryClient = useQueryClient(); @@ -102,15 +103,11 @@ export default function ProfileSettingsPage() { if (isLoading) { return ( -
-
- - -
-
- - - +
+ +
+ +
); @@ -120,38 +117,72 @@ export default function ProfileSettingsPage() { return null; } + const stats = [ + { label: settingsConfig.profile.stats.handle, value: `@${session.user.handle}` }, + { label: settingsConfig.profile.stats.email, value: session.user.email }, + { label: settingsConfig.profile.stats.role, value: session.user.role }, + ]; + return ( -
-
-

Profile Settings

-

- Manage your public profile information and avatar -

-
+
+
+
+
+
+ {settingsConfig.profile.marker} + + {settingsConfig.profile.badge} + +
+

+ {settingsConfig.profile.headline.line1} +
+ {settingsConfig.profile.headline.line2} +
+ + {settingsConfig.profile.headline.line3} + +

+

+ {settingsConfig.profile.description} +

+
+
+ {stats.map((stat) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+
+
-
-
+
+ +
- + - + {session.user.handle.slice(0, 2).toUpperCase()} {uploading ? ( -
+
) : null}
-
-
-

Profile Picture

-

- Upload a new avatar. Max 2MB. PNG or JPEG recommended. -

-
+

{settingsConfig.profile.avatar.helper}

fileInputRef.current?.click()} disabled={uploading} - className="rounded-xl" + className="h-11 rounded-none border-2 border-border px-5 text-xs font-bold uppercase" > - {uploading ? "Uploading..." : "Change Avatar"} + {uploading + ? settingsConfig.profile.avatar.uploading + : settingsConfig.profile.avatar.action}
+
-
- +
+
updateMutation.mutate(data))} - className="space-y-6" + className="space-y-6 border-2 border-border bg-card p-6" > ( - Display Name + + {settingsConfig.profile.form.nameLabel} + - +

- This is how others will see your name across the platform + {settingsConfig.profile.form.nameHelper}

)} @@ -208,18 +243,15 @@ export default function ProfileSettingsPage() { name="bio" render={({ field }) => ( - Bio + + {settingsConfig.profile.form.bioLabel} + -