feat: complete UI overhaul (liquid glass theme, i18n, FontAwesome)
This commit is contained in:
+87
-76
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Youtube } from "lucide-react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faYoutube } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faSun, faMoon, faMusic } from "@fortawesome/free-solid-svg-icons";
|
||||
import { InputPanel } from "../components/InputPanel";
|
||||
import type { InputValues } from "../components/InputPanel";
|
||||
import {
|
||||
@@ -10,7 +12,6 @@ import {
|
||||
import { StickyZipBar } from "../components/StickyZipBar";
|
||||
import { HistoryPanel } from "../components/HistoryPanel";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { EqualizerIcon } from "../components/EqualizerIcon";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { generateSong, getServerStatus } from "../lib/llm";
|
||||
import { addToHistory, type HistoryEntry } from "../lib/history";
|
||||
@@ -20,9 +21,9 @@ import {
|
||||
type SongAssets,
|
||||
type VideoPrompt,
|
||||
} from "../lib/types";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useTheme } from "../lib/theme";
|
||||
|
||||
// localStorage key for the in-progress input form. Lets the user survive
|
||||
// an accidental refresh without losing what they were typing.
|
||||
const DRAFT_KEY = "melodymuse-draft";
|
||||
const DRAFT_DEBOUNCE_MS = 400;
|
||||
|
||||
@@ -62,42 +63,33 @@ function loadDraft(): InputValues | null {
|
||||
|
||||
export function HomePage() {
|
||||
const toast = useToast();
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
// Hydrate the input from any previously-saved draft so the user can recover
|
||||
// an accidental refresh.
|
||||
const [input, setInput] = useState<InputValues>(
|
||||
() => loadDraft() ?? DEFAULT_INPUT,
|
||||
);
|
||||
const [assets, setAssets] = useState<SongAssets | null>(null);
|
||||
// The "last generated" snapshot. The cards compare their current editable
|
||||
// values against these to decide whether to show the Revert button.
|
||||
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState<RegeneratingMap>({});
|
||||
const [selectedTitleIndex, setSelectedTitleIndex] = useState(0);
|
||||
const [serverOk, setServerOk] = useState<boolean | null>(null);
|
||||
|
||||
// AbortController for the *current* network request. Refs are used so the
|
||||
// cancel handler always sees the latest value without re-binding.
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const anyRegenerating = isAnyBusy(loading, regenerating);
|
||||
|
||||
// Debounced auto-save: persist the input form ~400ms after the last edit so
|
||||
// a refresh doesn't wipe what the user was typing.
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
try {
|
||||
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input));
|
||||
} catch {
|
||||
// Quota or disabled — fine, history is the more durable store.
|
||||
// ignore
|
||||
}
|
||||
}, DRAFT_DEBOUNCE_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
// Check the server on mount and re-check periodically so the header
|
||||
// "Server offline" hint stays current without a manual refresh.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
@@ -116,7 +108,6 @@ export function HomePage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clean up any in-flight request on unmount.
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const selectedTitle = useMemo(() => {
|
||||
@@ -131,7 +122,6 @@ export function HomePage() {
|
||||
abortRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Esc cancels any in-flight generation.
|
||||
useEffect(() => {
|
||||
if (!anyRegenerating) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -144,9 +134,6 @@ export function HomePage() {
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [anyRegenerating, cancel]);
|
||||
|
||||
// Build the request payload from the current form values. Kept as a
|
||||
// function so all three call sites (full / regenerate all / per-section)
|
||||
// stay in sync.
|
||||
const buildRequest = useCallback(
|
||||
(
|
||||
section: GenerateCall["section"],
|
||||
@@ -172,7 +159,6 @@ export function HomePage() {
|
||||
busySetter: (b: boolean) => void,
|
||||
successMsg: string,
|
||||
) => {
|
||||
// Cancel any in-flight request before starting a new one.
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
@@ -185,12 +171,12 @@ export function HomePage() {
|
||||
toast.success(successMsg);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
toast.info("Generation cancelled");
|
||||
toast.info(t.generationCancelled);
|
||||
} else {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Generation failed — please try again",
|
||||
: t.generationFailed,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -198,13 +184,11 @@ export function HomePage() {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
[toast, t],
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!input.idea.trim()) return;
|
||||
// Clear the old assets so the skeleton state shows during generation
|
||||
// instead of the previous song's cards.
|
||||
setAssets(null);
|
||||
setOriginalAssets(null);
|
||||
await runGeneration(
|
||||
@@ -217,9 +201,9 @@ export function HomePage() {
|
||||
addToHistory(input, next);
|
||||
},
|
||||
setLoading,
|
||||
"Song assets generated",
|
||||
t.assetsGenerated,
|
||||
);
|
||||
}, [input, buildRequest, runGeneration]);
|
||||
}, [input, buildRequest, runGeneration, t]);
|
||||
|
||||
const handleRegenerateAll = useCallback(async () => {
|
||||
if (!input.idea.trim()) return;
|
||||
@@ -238,9 +222,9 @@ export function HomePage() {
|
||||
setLoading(b);
|
||||
setRegenerating((m) => ({ ...m, all: b }));
|
||||
},
|
||||
"Regenerated all sections",
|
||||
t.regeneratedAll,
|
||||
);
|
||||
}, [input, buildRequest, runGeneration]);
|
||||
}, [input, buildRequest, runGeneration, t]);
|
||||
|
||||
const handleRegenerateSection = useCallback(
|
||||
async (section: SectionKey) => {
|
||||
@@ -248,18 +232,15 @@ export function HomePage() {
|
||||
await runGeneration(
|
||||
() => buildRequest(section, { context: assets }),
|
||||
(result) => {
|
||||
// Merge partial into current assets, and update the original
|
||||
// snapshot for the keys that came back. Any keys the user has
|
||||
// hand-edited that the model *didn't* return stay as-is.
|
||||
const merged: SongAssets = { ...assets, ...result };
|
||||
setAssets(merged);
|
||||
setOriginalAssets((prev) => (prev ? { ...prev, ...result } : merged));
|
||||
},
|
||||
(b) => setRegenerating((m) => ({ ...m, [section]: b })),
|
||||
`Regenerated ${SECTION_LABELS[section]}`,
|
||||
`${t.regeneratedSection} ${SECTION_LABELS[section]}`,
|
||||
);
|
||||
},
|
||||
[input, assets, buildRequest, runGeneration],
|
||||
[input, assets, buildRequest, runGeneration, t],
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
@@ -273,7 +254,6 @@ export function HomePage() {
|
||||
}
|
||||
}, [assets, selectedTitle, toast]);
|
||||
|
||||
// Asset editing handlers
|
||||
const handleAssetChange = useCallback(
|
||||
<K extends keyof SongAssets>(key: K, value: SongAssets[K]) => {
|
||||
setAssets((curr) => (curr ? { ...curr, [key]: value } : curr));
|
||||
@@ -294,7 +274,6 @@ export function HomePage() {
|
||||
[],
|
||||
);
|
||||
|
||||
// Restore a previous generation from history.
|
||||
const handleLoadHistory = useCallback(
|
||||
(entry: HistoryEntry) => {
|
||||
if (anyRegenerating) cancel();
|
||||
@@ -308,61 +287,94 @@ export function HomePage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-32">
|
||||
<header className="sticky top-0 z-20 bg-bg/80 backdrop-blur border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<EqualizerIcon size={18} className="shrink-0" />
|
||||
<span className="text-sm font-medium text-fg-muted">
|
||||
MelodyMuse
|
||||
<div className="flex flex-col h-screen overflow-hidden">
|
||||
<div className="liquid-blob-1" />
|
||||
<div className="liquid-blob-2" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="shrink-0 z-20 bg-bg-card/40 backdrop-blur-md border-b border-border shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faMusic} className="text-accent-primary" />
|
||||
<span className="text-sm font-bold text-fg tracking-wide">
|
||||
{t.appTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://www.youtube.com/@AIWentNonsense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-fg-muted hover:text-fg transition-colors"
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold text-fg-muted hover:text-rose-500 transition-colors"
|
||||
title="AI Went Nonsense on YouTube"
|
||||
>
|
||||
<Youtube className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faYoutube} className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">YouTube</span>
|
||||
</a>
|
||||
{serverOk === false && (
|
||||
<span
|
||||
className="text-xs text-rose-300 hidden sm:inline"
|
||||
title="The MelodyMuse server is unreachable. Generation will fail."
|
||||
>
|
||||
Server offline
|
||||
<span className="text-xs font-bold text-rose-400 bg-rose-400/10 px-2 py-0.5 rounded" title="Server unreachable">
|
||||
{t.serverOffline}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 border-l border-border pl-4">
|
||||
<button
|
||||
onClick={() => setLang("de")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center text-[15px] transition-colors ${lang === "de" ? "bg-bg-hover shadow-sm" : "opacity-50 hover:opacity-100"}`}
|
||||
title="Deutsch"
|
||||
>
|
||||
🇩🇪
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLang("en")}
|
||||
className={`w-7 h-7 rounded flex items-center justify-center text-[15px] transition-colors ${lang === "en" ? "bg-bg-hover shadow-sm" : "opacity-50 hover:opacity-100"}`}
|
||||
title="English"
|
||||
>
|
||||
🇬🇧
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-7 h-7 rounded flex items-center justify-center text-fg-muted hover:text-accent-primary hover:bg-bg-hover transition-colors ml-1"
|
||||
title="Toggle Theme"
|
||||
>
|
||||
<FontAwesomeIcon icon={theme === "dark" ? faSun : faMoon} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 lg:py-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[35%_minmax(0,1fr)] gap-8">
|
||||
<div className="lg:sticky lg:top-20 lg:self-start space-y-4">
|
||||
<InputPanel
|
||||
values={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleGenerate}
|
||||
loading={loading}
|
||||
disabled={anyRegenerating}
|
||||
cancelButton={
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
Cancel generation
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{/* Main Layout: Fixed Input Top, Scrollable Bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-4 gap-6">
|
||||
|
||||
{/* Input Control Bar (Always visible at top) */}
|
||||
<div className="shrink-0 z-10 animate-fade-up">
|
||||
<InputPanel
|
||||
values={input}
|
||||
onChange={setInput}
|
||||
onSubmit={handleGenerate}
|
||||
loading={loading}
|
||||
disabled={anyRegenerating}
|
||||
cancelButton={
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
className="btn-secondary w-full"
|
||||
>
|
||||
{t.cancelGeneration}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom Area (History Sidebar + Results Area) */}
|
||||
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden animate-fade-up stagger-1">
|
||||
{/* History Sidebar */}
|
||||
<div className="hidden lg:flex flex-col w-1/4 shrink-0 overflow-hidden">
|
||||
<HistoryPanel onLoad={handleLoadHistory} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Results Main Area */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin pb-20">
|
||||
<ResultsPanel
|
||||
assets={assets}
|
||||
originalAssets={originalAssets}
|
||||
@@ -377,9 +389,10 @@ export function HomePage() {
|
||||
anyRegenerating={anyRegenerating}
|
||||
regenerating={regenerating}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{assets && (
|
||||
<StickyZipBar
|
||||
@@ -388,8 +401,6 @@ export function HomePage() {
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+31
-40
@@ -1,16 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Plug,
|
||||
Trash2,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
faArrowLeft,
|
||||
faCheckCircle,
|
||||
faExclamationTriangle,
|
||||
faCircleNotch,
|
||||
faPlug,
|
||||
faTrashAlt,
|
||||
faServer,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { useToast } from "../lib/toast";
|
||||
import { getServerStatus, type ServerStatus } from "../lib/llm";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
const HISTORY_KEY = "melodymuse-history";
|
||||
const DRAFT_KEY = "melodymuse-draft";
|
||||
@@ -44,6 +46,7 @@ function readLocalDataSummary(): LocalDataSummary {
|
||||
export function SettingsPage() {
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
@@ -100,7 +103,7 @@ export function SettingsPage() {
|
||||
const onClearAll = () => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Clear all MelodyMuse data from this browser? This will remove the recent generations, the in-progress draft, and the theme preference. The API key on the server is NOT affected.",
|
||||
"Clear all MelodyMuse data from this browser? This will remove the recent generations, the in-progress draft, and the theme preference.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -121,9 +124,9 @@ export function SettingsPage() {
|
||||
className="p-2 -ml-2 rounded-md text-fg-muted hover:text-fg hover:bg-bg-hover transition-colors"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<FontAwesomeIcon icon={faArrowLeft} className="w-4 h-4" />
|
||||
</button>
|
||||
<h1 className="text-sm font-semibold text-fg">Settings</h1>
|
||||
<h1 className="text-sm font-semibold text-fg">{t.settings}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -131,7 +134,7 @@ export function SettingsPage() {
|
||||
<div className="card p-6 space-y-6">
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faServer} className="w-4 h-4 text-accent-primary" />
|
||||
Server status
|
||||
</h2>
|
||||
|
||||
@@ -158,7 +161,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
{statusError && !checking && (
|
||||
<p className="text-xs text-rose-300 break-words">
|
||||
<p className="text-xs text-rose-400 break-words">
|
||||
{statusError}
|
||||
</p>
|
||||
)}
|
||||
@@ -171,21 +174,13 @@ export function SettingsPage() {
|
||||
className="btn-ghost"
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<FontAwesomeIcon icon={faCircleNotch} spin className="w-4 h-4" />
|
||||
) : (
|
||||
<Plug className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faPlug} className="w-4 h-4" />
|
||||
)}
|
||||
<span>Re-check</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-fg-muted">
|
||||
The LLM provider's API key lives on this server in the{" "}
|
||||
<code className="px-1 py-0.5 rounded bg-bg-hover border border-border font-mono">
|
||||
LLM_API_KEY
|
||||
</code>{" "}
|
||||
environment variable. The browser never sees it.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -199,16 +194,16 @@ export function SettingsPage() {
|
||||
</p>
|
||||
<ul className="text-xs text-fg-muted space-y-1 mb-4">
|
||||
<li>
|
||||
<code className="font-mono">melodymuse-history</code> —{" "}
|
||||
<code className="font-mono text-accent-primary">melodymuse-history</code> —{" "}
|
||||
{summary.historyEntries} recent generation
|
||||
{summary.historyEntries === 1 ? "" : "s"}
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">melodymuse-draft</code> —{" "}
|
||||
<code className="font-mono text-accent-primary">melodymuse-draft</code> —{" "}
|
||||
{summary.hasDraft ? "saved (in-progress input)" : "empty"}
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">melodymuse-theme</code> —{" "}
|
||||
<code className="font-mono text-accent-primary">melodymuse-theme</code> —{" "}
|
||||
{summary.hasTheme ? "set" : "using default"}
|
||||
</li>
|
||||
</ul>
|
||||
@@ -219,7 +214,7 @@ export function SettingsPage() {
|
||||
disabled={summary.historyEntries === 0}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Clear recent generations
|
||||
</button>
|
||||
<button
|
||||
@@ -228,30 +223,26 @@ export function SettingsPage() {
|
||||
disabled={!summary.hasDraft}
|
||||
className="btn-secondary"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Clear in-progress draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearAll}
|
||||
className="btn-secondary text-rose-300 hover:text-rose-200"
|
||||
className="btn-secondary text-rose-400 hover:text-rose-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<FontAwesomeIcon icon={faTrashAlt} className="w-4 h-4" />
|
||||
Clear all local data
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-center text-xs text-fg-muted">
|
||||
API key on the server · Recent generations in this browser only
|
||||
</p>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-center pt-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-xs text-fg-muted hover:text-fg transition-colors"
|
||||
className="text-xs font-semibold text-fg-muted hover:text-accent-primary transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
← Back to generator
|
||||
<FontAwesomeIcon icon={faArrowLeft} /> Back to generator
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,7 +253,7 @@ export function SettingsPage() {
|
||||
|
||||
function StatusDot({ checking, ok }: { checking: boolean; ok: boolean }) {
|
||||
if (checking)
|
||||
return <Loader2 className="w-5 h-5 text-fg-muted animate-spin shrink-0" />;
|
||||
if (ok) return <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0" />;
|
||||
return <AlertTriangle className="w-5 h-5 text-rose-400 shrink-0" />;
|
||||
return <FontAwesomeIcon icon={faCircleNotch} spin className="w-5 h-5 text-fg-muted shrink-0" />;
|
||||
if (ok) return <FontAwesomeIcon icon={faCheckCircle} className="w-5 h-5 text-emerald-400 shrink-0" />;
|
||||
return <FontAwesomeIcon icon={faExclamationTriangle} className="w-5 h-5 text-rose-400 shrink-0" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user