feat: custom CSS editor with CodeMirror, live preview, and /docs/css reference

This commit is contained in:
2026-05-24 03:26:34 +02:00
parent fccc4064cf
commit f4932af2ba
7 changed files with 493 additions and 5 deletions

View File

@@ -0,0 +1,315 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "CSS Customization Reference",
description: "All the IDs, variables, and examples you need to style your Thoughts profile.",
};
const EXAMPLE_THEMES = [
{
name: "Vaporwave",
description: "Pink/purple gradient header with a glassy card",
css: `#profile-header {
background: linear-gradient(135deg, #ff6b9d 0%, #c44dff 100%);
}
#profile-card {
background: rgba(255, 107, 157, 0.08);
border: 1px solid rgba(196, 77, 255, 0.3);
backdrop-filter: blur(20px);
}`,
},
{
name: "Dark Minimal",
description: "Pitch black, thin border, no blur",
css: `#main-container {
--background: 0 0% 4%;
--card: 0 0% 7%;
--border: 0 0% 14%;
}
#profile-header {
background: #0a0a0a;
border-bottom: 1px solid #1a1a1a;
}`,
},
{
name: "Neon Glow",
description: "Electric green borders and glow on a dark background",
css: `#profile-header {
background: #050505;
border-bottom: 2px solid #00ff88;
}
#profile-card {
border: 1px solid #00ff88;
box-shadow: 0 0 24px rgba(0, 255, 136, 0.25);
background: rgba(0, 255, 136, 0.04);
}`,
},
{
name: "Pastel",
description: "Soft pink and lavender, gentle and rounded",
css: `#profile-header {
background: linear-gradient(135deg, #ffd6e7 0%, #c7ceea 100%);
}
#profile-card {
background: rgba(255, 214, 231, 0.25);
border: 1px solid #ffd6e7;
border-radius: 1.5rem;
}`,
},
{
name: "Retro Terminal",
description: "Green-on-black monospace, old school CRT feel",
css: `#main-container {
--background: 0 0% 4%;
--foreground: 120 100% 70%;
--card: 0 0% 6%;
--border: 120 60% 20%;
font-family: 'Courier New', monospace;
}
#profile-header {
background: #000;
border-bottom: 2px solid #00cc44;
}
#profile-card {
border: 1px solid #00cc44;
box-shadow: 0 0 8px rgba(0, 204, 68, 0.3);
}`,
},
];
const IDS = [
{ id: "#profile-page-{username}", description: "Root element — scope all rules here to avoid leaking to other pages" },
{ id: "#profile-header", description: "Banner image strip at the top of the page" },
{ id: "#main-container", description: "Full-width grid wrapper — the whole page body" },
{ id: "#left-sidebar", description: "The <aside> wrapping the entire left column" },
{ id: "#left-sidebar__inner", description: "Sticky inner wrapper (profile card + top friends)" },
{ id: "#profile-card", description: "Profile sidebar card" },
{ id: "#profile-card__inner", description: "Flex row containing avatar + follow/edit button" },
{ id: "#profile-card__avatar", description: "Avatar section wrapper" },
{ id: "#profile-card__avatar-image", description: "Avatar circle container" },
{ id: "#profile-card__action", description: "Follow / Edit profile button container" },
{ id: "#profile-card__info", description: "Display name and @username block" },
{ id: "#profile-card__name", description: "Display name <h1>" },
{ id: "#profile-card__username", description: "@username paragraph" },
{ id: "#profile-card__bio", description: "Bio paragraph" },
{ id: "#profile-card__stats", description: "Following / Followers count row" },
{ id: "#profile-card__joined", description: "Joined date row" },
{ id: "#profile-card__thoughts", description: "Right column containing the thoughts feed" },
{ id: "#top-friends", description: "Top Friends card" },
{ id: "#top-friends__header", description: "Top Friends card header" },
{ id: "#top-friends__title", description: "\"Top Friends\" title" },
{ id: "#top-friends__content", description: "Top Friends avatar list" },
];
const CLASSES = [
{ name: ".glass-effect", description: "Semi-transparent card with backdrop blur and border — applied to #profile-card by default" },
{ name: ".gloss-highlight", description: "Adds Frutiger Aero gloss shimmer via ::before pseudo-element — add to any card" },
{ name: ".avatar-gradient", description: "Gradient fallback background + ring applied to the avatar circle" },
{ name: ".profile-header", description: "Class on the banner div (same element as #profile-header)" },
];
const VARIABLES = [
{ name: "--background", description: "Page background colour" },
{ name: "--foreground", description: "Default text colour" },
{ name: "--card", description: "Card background colour" },
{ name: "--card-foreground", description: "Text colour inside cards" },
{ name: "--primary", description: "Primary accent colour (buttons, active tabs)" },
{ name: "--muted", description: "Muted background (used for secondary sections)" },
{ name: "--muted-foreground", description: "Secondary text colour" },
{ name: "--border", description: "Border colour" },
{ name: "--radius", description: "Base border radius for all rounded elements" },
];
const AERO_VARIABLES = [
{ name: "--shadow-fa-sm", description: "Small Frutiger Aero layered box-shadow" },
{ name: "--shadow-fa-md", description: "Medium Frutiger Aero layered box-shadow" },
{ name: "--shadow-fa-lg", description: "Large Frutiger Aero layered box-shadow — used on cards" },
{ name: "--gradient-fa-blue", description: "Signature blue gradient direction (135deg, sky → cyan)" },
{ name: "--gradient-fa-green", description: "Green gradient variant" },
{ name: "--text-shadow-default", description: "Subtle dark text shadow" },
{ name: "--text-shadow-sm", description: "Light text shadow (white highlight)" },
{ name: "--text-shadow-md", description: "Medium dark text shadow" },
{ name: "--text-shadow-lg", description: "Strong dark text shadow" },
];
export default function CssReferencePage() {
return (
<div className="container mx-auto max-w-3xl px-4 py-12 space-y-12">
<div>
<h1 className="text-3xl font-bold mb-2">CSS Customization Reference</h1>
<p className="text-muted-foreground">
Style every part of your profile page. Write CSS in{" "}
<a href="/settings/profile" className="underline hover:text-foreground">
Settings Profile
</a>{" "}
and preview it live before saving.
</p>
</div>
{/* Targetable IDs */}
<section>
<h2 className="text-xl font-semibold mb-1">Targetable IDs</h2>
<p className="text-sm text-muted-foreground mb-4">
Use these IDs in your CSS selectors to target specific elements on your profile page.
Prefix with <code className="bg-muted px-1 rounded">#profile-page-yourusername</code> to
avoid affecting other pages.
</p>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="text-left px-4 py-2 font-medium">Selector</th>
<th className="text-left px-4 py-2 font-medium">Element</th>
</tr>
</thead>
<tbody className="divide-y">
{IDS.map(({ id, description }) => (
<tr key={id} className="odd:bg-muted/60 even:bg-muted/80 transition-colors">
<td className="px-4 py-2 font-mono text-xs text-primary">{id}</td>
<td className="px-4 py-2 text-muted-foreground">{description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* Custom Classes */}
<section>
<h2 className="text-xl font-semibold mb-1">Custom Classes</h2>
<p className="text-sm text-muted-foreground mb-4">
These classes are defined globally and can be added to or overridden for any element.
</p>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="text-left px-4 py-2 font-medium">Class</th>
<th className="text-left px-4 py-2 font-medium">Effect</th>
</tr>
</thead>
<tbody className="divide-y">
{CLASSES.map(({ name, description }) => (
<tr key={name} className="odd:bg-muted/60 even:bg-muted/80 transition-colors">
<td className="px-4 py-2 font-mono text-xs text-primary">{name}</td>
<td className="px-4 py-2 text-muted-foreground">{description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* CSS Variables */}
<section>
<h2 className="text-xl font-semibold mb-1">Theme Variables</h2>
<p className="text-sm text-muted-foreground mb-4">
Override these tokens to change colours globally. Set them on{" "}
<code className="bg-muted px-1 rounded">#main-container</code> or{" "}
<code className="bg-muted px-1 rounded">:root</code>. Values are bare HSL triplets
for example <code className="bg-muted px-1 rounded">--background: 220 14% 96%</code>{" "}
(hue saturation lightness, consumed as{" "}
<code className="bg-muted px-1 rounded">hsl(var(--background))</code>).
</p>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="text-left px-4 py-2 font-medium">Variable</th>
<th className="text-left px-4 py-2 font-medium">Controls</th>
</tr>
</thead>
<tbody className="divide-y">
{VARIABLES.map(({ name, description }) => (
<tr key={name} className="odd:bg-muted/60 even:bg-muted/80 transition-colors">
<td className="px-4 py-2 font-mono text-xs text-primary">{name}</td>
<td className="px-4 py-2 text-muted-foreground">{description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* Frutiger Aero Variables */}
<section>
<h2 className="text-xl font-semibold mb-1">Frutiger Aero Variables</h2>
<p className="text-sm text-muted-foreground mb-4">
The design system defines these extra variables for shadows, gradients, and text effects.
Use them with <code className="bg-muted px-1 rounded">var()</code> in your CSS.
</p>
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted">
<tr>
<th className="text-left px-4 py-2 font-medium">Variable</th>
<th className="text-left px-4 py-2 font-medium">Effect</th>
</tr>
</thead>
<tbody className="divide-y">
{AERO_VARIABLES.map(({ name, description }) => (
<tr key={name} className="odd:bg-muted/60 even:bg-muted/80 transition-colors">
<td className="px-4 py-2 font-mono text-xs text-primary">{name}</td>
<td className="px-4 py-2 text-muted-foreground">{description}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* Example Themes */}
<section>
<h2 className="text-xl font-semibold mb-1">Example Themes</h2>
<p className="text-sm text-muted-foreground mb-4">
Copy and paste any of these into the editor as a starting point.
</p>
<div className="space-y-6">
{EXAMPLE_THEMES.map(({ name, description, css }) => (
<div key={name} className="rounded-lg border overflow-hidden">
<div className="px-4 py-3 border-b bg-muted/50">
<p className="font-medium text-sm">{name}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<pre className="p-4 text-xs overflow-x-auto bg-[#0d1117] text-[#e6edf3]">
<code>{css}</code>
</pre>
</div>
))}
</div>
</section>
{/* Tips */}
<section>
<h2 className="text-xl font-semibold mb-3">Tips</h2>
<ul className="space-y-2 text-sm text-muted-foreground list-disc list-inside">
<li>
Scope all your rules to{" "}
<code className="bg-muted px-1 rounded text-foreground">#profile-page-yourusername</code>{" "}
to prevent them leaking to other pages.
</li>
<li>
Use <code className="bg-muted px-1 rounded text-foreground">var(--card)</code> instead of
hardcoded colours so your theme adapts to light and dark mode.
</li>
<li>
The layout stacks to a single column below{" "}
<code className="bg-muted px-1 rounded text-foreground">1024px</code>. Use{" "}
<code className="bg-muted px-1 rounded text-foreground">@media (max-width: 1024px)</code>{" "}
to adjust for mobile.
</li>
<li>
Hex, rgb, and hsl all work. For theme token overrides, use bare HSL triplets:{" "}
<code className="bg-muted px-1 rounded text-foreground">--card: 220 14% 12%</code>.
</li>
</ul>
</section>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
getUserThoughts,
Me,
} from "@/lib/api";
import { CssPreviewListener } from "@/components/css-preview-listener";
interface ProfilePageProps {
params: Promise<{ username: string }>;
@@ -134,6 +135,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
{user.customCss && (
<style dangerouslySetInnerHTML={{ __html: user.customCss }} />
)}
<CssPreviewListener />
<div
id="profile-header"

View File

@@ -4,6 +4,7 @@
"": {
"name": "thoughts-frontend",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -32,6 +33,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-cookie": "^3.0.6",
"@uiw/react-codemirror": "^4.25.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -73,6 +75,24 @@
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ=="],
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
"@codemirror/lint": ["@codemirror/lint@6.9.6", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A=="],
"@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="],
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
"@codemirror/theme-one-dark": ["@codemirror/theme-one-dark@6.1.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA=="],
"@codemirror/view": ["@codemirror/view@6.43.0", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA=="],
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
"@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="],
@@ -173,6 +193,16 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/env": ["@next/env@15.5.7", "", {}, "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg=="],
@@ -403,6 +433,10 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.42.0", "", { "dependencies": { "@typescript-eslint/types": "8.42.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ=="],
"@uiw/codemirror-extensions-basic-setup": ["@uiw/codemirror-extensions-basic-setup@4.25.10", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ=="],
"@uiw/react-codemirror": ["@uiw/react-codemirror@4.25.10", "", { "dependencies": { "@babel/runtime": "^7.18.6", "@codemirror/commands": "^6.1.0", "@codemirror/state": "^6.1.1", "@codemirror/theme-one-dark": "^6.0.0", "@uiw/codemirror-extensions-basic-setup": "4.25.10", "codemirror": "^6.0.0" }, "peerDependencies": { "@codemirror/view": ">=6.0.0", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="],
@@ -511,6 +545,8 @@
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@@ -521,6 +557,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -1015,6 +1053,8 @@
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1075,6 +1115,8 @@
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],

View File

@@ -0,0 +1,100 @@
"use client";
import { useEffect, useRef, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { css } from "@codemirror/lang-css";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ExternalLink } from "lucide-react";
interface CssEditorProps {
value?: string;
onChange?: (value: string) => void;
username: string;
}
export function CssEditor({ value = "", onChange, username }: CssEditorProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [iframeReady, setIframeReady] = useState(false);
useEffect(() => {
if (!iframeReady) return;
const iframe = iframeRef.current;
if (!iframe) return;
const timer = setTimeout(() => {
iframe.contentWindow?.postMessage(
{ type: "css-preview", css: value },
window.location.origin
);
}, 150);
return () => clearTimeout(timer);
}, [value, iframeReady]);
return (
<div className="rounded-lg border bg-card overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="text-sm font-medium">Custom CSS</span>
<a
href="/docs/css"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<ExternalLink className="h-3 w-3" />
CSS Reference
</a>
</div>
{/* Tabs */}
<Tabs defaultValue="edit">
<TabsList className="w-full justify-start rounded-none border-b h-auto p-0 bg-transparent">
<TabsTrigger
value="edit"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 text-sm"
>
Edit
</TabsTrigger>
<TabsTrigger
value="preview"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 text-sm"
>
Preview
</TabsTrigger>
</TabsList>
<TabsContent value="edit" className="mt-0">
<CodeMirror
value={value}
height="280px"
extensions={[css()]}
theme="dark"
onChange={(val) => onChange?.(val)}
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
}}
/>
</TabsContent>
<TabsContent value="preview" className="mt-0 data-[state=inactive]:hidden" forceMount>
<div className="relative">
<iframe
ref={iframeRef}
src={`/users/${username}`}
onLoad={() => setIframeReady(true)}
className="w-full border-0"
style={{ height: "560px" }}
title="Profile preview"
/>
<div className="absolute top-2 right-2 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded">
Live preview
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
export function CssPreviewListener() {
useEffect(() => {
if (window.self === window.top) return;
function handler(e: MessageEvent) {
if (e.origin !== window.location.origin) return;
if (!e.data || e.data.type !== "css-preview") return;
let el = document.getElementById("css-preview-style") as HTMLStyleElement | null;
if (!el) {
el = document.createElement("style");
el.id = "css-preview-style";
document.head.appendChild(el);
}
el.textContent = typeof e.data.css === "string" ? e.data.css : "";
}
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
return null;
}

View File

@@ -21,6 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { UserAvatar } from "@/components/user-avatar";
import { CssEditor } from "@/components/css-editor";
import { Camera, ImagePlus, Loader2 } from "lucide-react";
interface EditProfileFormProps {
@@ -199,12 +200,11 @@ export function EditProfileForm({ currentUser, token }: EditProfileFormProps) {
control={form.control}
render={({ field }) => (
<FormItem>
<FormLabel>Custom CSS</FormLabel>
<FormControl>
<Textarea
placeholder="body { font-family: 'Comic Sans MS'; }"
rows={5}
{...field}
<CssEditor
value={field.value ?? ""}
onChange={field.onChange}
username={currentUser.username}
/>
</FormControl>
<FormMessage />

View File

@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -37,6 +38,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-cookie": "^3.0.6",
"@uiw/react-codemirror": "^4.25.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",