feat: custom CSS editor with CodeMirror, live preview, and /docs/css reference
This commit is contained in:
315
thoughts-frontend/app/docs/css/page.tsx
Normal file
315
thoughts-frontend/app/docs/css/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
100
thoughts-frontend/components/css-editor.tsx
Normal file
100
thoughts-frontend/components/css-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
thoughts-frontend/components/css-preview-listener.tsx
Normal file
27
thoughts-frontend/components/css-preview-listener.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user