feat: custom CSS editor with CodeMirror, live preview, and /docs/css reference
This commit is contained in:
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 />
|
||||
|
||||
Reference in New Issue
Block a user