Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f293ce67 | ||
|
|
d04fbbe73c | ||
|
|
11b5e6929a | ||
|
|
07731e888c | ||
|
|
4e2a60b792 |
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"name": "Iceberg Server",
|
"name": "Iceberg Server",
|
||||||
"welcome": "Welcome to your Vite + React + Tailwind app",
|
"welcome": "Welcome to Iceberg Chat. Log in to start.",
|
||||||
"start": "Get Started"
|
"start": "Get Started"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"name": "Iceberg Server",
|
"name": "Iceberg Server",
|
||||||
"welcome": "Bienvenue sur votre application Vite + React + Tailwind",
|
"welcome": "Bienvenue sur Iceberg Chat. Veuillez vous connecter pour continuer.",
|
||||||
"start": "Commencer"
|
"start": "Commencer"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function Home() {
|
|||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-1 flex-col items-center justify-center">
|
||||||
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
|
<h1 className="text-5xl font-bold text-cyan-400">{t('name')}</h1>
|
||||||
<p className="mt-4 text-gray-400">{t('welcome')}</p>
|
<p className="mt-4 text-gray-400">{t('welcome')}</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+17
-18
@@ -24,21 +24,21 @@ function useIsWide(): boolean {
|
|||||||
/* -----------------------------
|
/* -----------------------------
|
||||||
Shared sidebar logic
|
Shared sidebar logic
|
||||||
------------------------------*/
|
------------------------------*/
|
||||||
function useSidebarState() {
|
function useSidebarState(panels: SidebarPanel[], isWide: boolean) {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// open first panel by default ONLY on first mount (desktop only)
|
||||||
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
|
if (!initialized && isWide) {
|
||||||
|
setActiveId(panels[0]?.id ?? null);
|
||||||
|
setInitialized(true);
|
||||||
|
}
|
||||||
|
|
||||||
const setClosed = () => setActiveId(null);
|
const setClosed = () => setActiveId(null);
|
||||||
|
|
||||||
const toggle = (id: string, effectiveId: string | null) => {
|
const toggle = (id: string) => {
|
||||||
setActiveId((prev) =>
|
setActiveId((prev) => (prev === id ? null : id));
|
||||||
prev === id || (effectiveId === id && prev === null) ? null : id,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveEffectiveId = (isWide: boolean, panels: SidebarPanel[]) => {
|
|
||||||
return activeId === null
|
|
||||||
? null
|
|
||||||
: (activeId ?? (isWide ? (panels[0]?.id ?? null) : null));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -46,7 +46,6 @@ function useSidebarState() {
|
|||||||
setActiveId,
|
setActiveId,
|
||||||
setClosed,
|
setClosed,
|
||||||
toggle,
|
toggle,
|
||||||
resolveEffectiveId,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +64,13 @@ function DesktopSidebar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
flex transition-all duration-300 ease-in-out overflow-hidden
|
flex transition-all duration-300 ease overflow-hidden
|
||||||
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
|
${isOpen ? 'w-64 opacity-100' : 'w-0 opacity-0'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{activePanel && (
|
{activePanel && (
|
||||||
<div className="w-64 flex flex-col border-r border-muted">
|
<div className="w-64 flex flex-col border-r border-muted transition-theme">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-muted shrink-0 transition-theme">
|
||||||
<span className="text-sm font-semibold text-accent">
|
<span className="text-sm font-semibold text-accent">
|
||||||
{activePanel.label}
|
{activePanel.label}
|
||||||
</span>
|
</span>
|
||||||
@@ -159,14 +158,14 @@ function IconRail({
|
|||||||
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
|
export default function Sidebar({ panels }: { panels: SidebarPanel[] }) {
|
||||||
const isWide = useIsWide();
|
const isWide = useIsWide();
|
||||||
|
|
||||||
const { setClosed, toggle, resolveEffectiveId } = useSidebarState();
|
const { activeId, setClosed, toggle } = useSidebarState(panels, isWide);
|
||||||
|
|
||||||
const effectiveId = resolveEffectiveId(isWide, panels);
|
const effectiveId = activeId;
|
||||||
const activePanel = panels.find((p) => p.id === effectiveId);
|
const activePanel = panels.find((p) => p.id === effectiveId);
|
||||||
const isOpen = !!activePanel;
|
const isOpen = !!activePanel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex border-r border-t border-muted">
|
<div className="flex border-r border-t border-muted transition-theme">
|
||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
{isWide && (
|
{isWide && (
|
||||||
<DesktopSidebar
|
<DesktopSidebar
|
||||||
|
|||||||
+119
-27
@@ -8,6 +8,7 @@ import { Session } from 'next-auth';
|
|||||||
import { Message, ModelInfo } from '@/types/chat-api';
|
import { Message, ModelInfo } from '@/types/chat-api';
|
||||||
import { ChatRequest } from '@/types/chat-api';
|
import { ChatRequest } from '@/types/chat-api';
|
||||||
import { Settings } from './chat.panels';
|
import { Settings } from './chat.panels';
|
||||||
|
import { fetchChatStream, readStreamChunks } from './stream';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
session: Session;
|
session: Session;
|
||||||
@@ -16,6 +17,9 @@ type Props = {
|
|||||||
setSelectedModel: (v: string) => void;
|
setSelectedModel: (v: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sentinel role used while the assistant message is being streamed in
|
||||||
|
const ASSISTANT_ROLE = 'assistant' as const;
|
||||||
|
|
||||||
export default function ChatUI({
|
export default function ChatUI({
|
||||||
session,
|
session,
|
||||||
models,
|
models,
|
||||||
@@ -39,17 +43,26 @@ export default function ChatUI({
|
|||||||
keepAlive: '5m',
|
keepAlive: '5m',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showError = (message: string) => {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ role: ASSISTANT_ROLE, content: `❌ ${message}` },
|
||||||
|
]);
|
||||||
|
setNotification(message);
|
||||||
|
setTimeout(() => setNotification(null), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
if (!input.trim() || !selectedModel) return;
|
if (!input.trim() || !selectedModel) return;
|
||||||
const userMessage = { role: 'user' as const, content: input };
|
|
||||||
const newMessages = [...messages, userMessage];
|
const userMessage: Message = { role: 'user', content: input };
|
||||||
|
const newMessages: Message[] = [...messages, userMessage];
|
||||||
setMessages(newMessages);
|
setMessages(newMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
|
||||||
const body: ChatRequest = {
|
const body: ChatRequest = {
|
||||||
model: selectedModel!,
|
model: selectedModel,
|
||||||
messages: newMessages,
|
messages: newMessages,
|
||||||
temperature: settings.temp,
|
temperature: settings.temp,
|
||||||
top_p: settings.topP,
|
top_p: settings.topP,
|
||||||
@@ -63,34 +76,89 @@ export default function ChatUI({
|
|||||||
stop: settings.stop,
|
stop: settings.stop,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(
|
const endpoint = process.env.NEXT_PUBLIC_API_URL + '/chat/completions';
|
||||||
process.env.NEXT_PUBLIC_API_URL + '/chat/completions',
|
|
||||||
{
|
try {
|
||||||
|
if (settings.stream) {
|
||||||
|
await handleStream(endpoint, body, newMessages);
|
||||||
|
} else {
|
||||||
|
await handleNonStream(endpoint, body);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
showError(err?.message ?? 'Something went wrong while sending request');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Non-streaming path ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleNonStream = async (endpoint: string, body: ChatRequest) => {
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${session?.accessToken}`,
|
Authorization: `Bearer ${session?.accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
const data = await res.json();
|
|
||||||
const assistant = data?.choices?.[0]?.message;
|
|
||||||
if (assistant) setMessages((prev) => [...prev, assistant]);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setNotification(
|
|
||||||
err?.message || 'Something went wrong while sending request',
|
|
||||||
);
|
|
||||||
|
|
||||||
setTimeout(() => setNotification(null), 3000);
|
const data = await res.json().catch(() => null);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
if (!res.ok) {
|
||||||
|
let message = 'Request failed';
|
||||||
|
if (res.status === 500) message = 'Server error (likely model too large or OOM)';
|
||||||
|
else if (res.status === 401) message = 'Unauthorized';
|
||||||
|
else message = data?.message ?? data?.error ?? message;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistant = data?.choices?.[0]?.message;
|
||||||
|
if (assistant) {
|
||||||
|
setMessages((prev) => [...prev, assistant]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Streaming path ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const handleStream = async (
|
||||||
|
endpoint: string,
|
||||||
|
body: ChatRequest,
|
||||||
|
priorMessages: Message[],
|
||||||
|
) => {
|
||||||
|
// Append an empty assistant message that we'll fill in as tokens arrive
|
||||||
|
const placeholderIndex = priorMessages.length; // index in the upcoming state
|
||||||
|
setMessages((prev) => [...prev, { role: ASSISTANT_ROLE, content: '' }]);
|
||||||
|
|
||||||
|
const response = await fetchChatStream(
|
||||||
|
endpoint,
|
||||||
|
body,
|
||||||
|
session?.accessToken as string,
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const { content, done } of readStreamChunks(response)) {
|
||||||
|
if (done) break;
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const target = next[placeholderIndex];
|
||||||
|
if (target) {
|
||||||
|
next[placeholderIndex] = {
|
||||||
|
...target,
|
||||||
|
content: target.content + content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 bg-primary text-primary transition-theme">
|
<div className="flex flex-1 bg-primary text-primary transition-theme relative">
|
||||||
{notification && (
|
{notification && (
|
||||||
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50">
|
<div className="fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-lg shadow-lg z-50">
|
||||||
{notification}
|
{notification}
|
||||||
@@ -117,20 +185,44 @@ export default function ChatUI({
|
|||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div key={i} className="text-sm">
|
<div key={i} className="text-sm">
|
||||||
<b>{msg.role === 'user' ? 'You' : 'AI'}:</b> {msg.content}
|
<b>{msg.role === 'user' ? 'You' : 'AI'}:</b> {msg.content}
|
||||||
|
{/* Blinking cursor shown on the last message while streaming */}
|
||||||
|
{loading &&
|
||||||
|
settings.stream &&
|
||||||
|
i === messages.length - 1 &&
|
||||||
|
msg.role === ASSISTANT_ROLE && (
|
||||||
|
<span className="inline-block w-[2px] h-[1em] bg-current ml-0.5 align-middle animate-pulse" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{loading && <div className="text-xs text-muted">Thinking...</div>}
|
{loading && !settings.stream && (
|
||||||
|
<div className="text-xs text-muted">Thinking...</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input — floats center when empty, slides to bottom on first message */}
|
||||||
<div className="p-4 border-t border-white/10">
|
<div
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
className={`
|
||||||
|
transition-all duration-500 ease-in-out
|
||||||
|
${
|
||||||
|
messages.length === 0
|
||||||
|
? 'absolute inset-0 flex items-center justify-center pointer-events-none'
|
||||||
|
: 'p-4'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-2xl mx-auto pointer-events-auto transition-all duration-500 ease-in-out">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="text-center text-primary text-sm mb-3 opacity-60">
|
||||||
|
Start a conversation…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
|
onKeyDown={(e) => e.key === 'Enter' && !loading && sendMessage()}
|
||||||
className="w-full p-3 rounded-xl bg-black/20 border border-muted outline-none"
|
disabled={loading}
|
||||||
|
className="w-full p-3 rounded-xl bg-black/20 border border-muted outline-none disabled:opacity-50"
|
||||||
placeholder="Message..."
|
placeholder="Message..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function ConversationHistoryPanel() {
|
|||||||
className="flex flex-col px-3 py-2 rounded-lg hover:bg-white/10 cursor-pointer transition-colors"
|
className="flex flex-col px-3 py-2 rounded-lg hover:bg-white/10 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium truncate">{h.title}</span>
|
<span className="text-sm font-medium truncate">{h.title}</span>
|
||||||
<span className="text-xs text-white/40">{h.time}</span>
|
<span className="text-xs text-secondary">{h.time}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* chat.stream.ts
|
||||||
|
*
|
||||||
|
* Helpers for consuming a streaming chat-completions response (SSE format).
|
||||||
|
* The server must send chunks as:
|
||||||
|
* data: {"choices":[{"delta":{"content":"..."}}]}\n\n
|
||||||
|
* data: [DONE]\n\n
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StreamChunk = {
|
||||||
|
content: string;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async-generator that yields text deltas from a streaming fetch response.
|
||||||
|
* Handles both pure SSE (`data: ...`) and raw JSON-lines.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* for await (const { content, done } of readStreamChunks(response)) {
|
||||||
|
* if (done) break;
|
||||||
|
* setPartial(prev => prev + content);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function* readStreamChunks(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<StreamChunk> {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null — cannot stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
// Flush any remaining buffer content
|
||||||
|
if (buffer.trim()) {
|
||||||
|
yield* parseBuffer(buffer);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Split on double-newline (SSE event boundary) or single newline (JSON-lines)
|
||||||
|
const parts = buffer.split(/\n\n|\n/);
|
||||||
|
|
||||||
|
// Keep the last (potentially incomplete) chunk in the buffer
|
||||||
|
buffer = parts.pop() ?? '';
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
yield* parseBuffer(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single SSE line or JSON line and yields a StreamChunk.
|
||||||
|
*/
|
||||||
|
function* parseBuffer(raw: string): Generator<StreamChunk> {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
// SSE lines start with "data: "
|
||||||
|
const jsonStr = line.startsWith('data: ') ? line.slice(6) : line;
|
||||||
|
|
||||||
|
if (jsonStr === '[DONE]') {
|
||||||
|
yield { content: '', done: true };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||||
|
if (typeof delta === 'string') {
|
||||||
|
yield { content: delta, done: false };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — silently skip (could be a comment or keep-alive ping)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the streaming fetch request and returns the raw Response.
|
||||||
|
* Throws on non-2xx status with a descriptive message.
|
||||||
|
*/
|
||||||
|
export async function fetchChatStream(
|
||||||
|
url: string,
|
||||||
|
body: object,
|
||||||
|
token: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = 'Request failed';
|
||||||
|
|
||||||
|
if (res.status === 500) {
|
||||||
|
message = 'Server error (likely model too large or OOM)';
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
message = 'Unauthorized';
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
message = data?.message ?? data?.error ?? message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
+8
-2
@@ -27,6 +27,7 @@ body {
|
|||||||
--bg-secondary: oklch(27.4% 0.006 286.033);
|
--bg-secondary: oklch(27.4% 0.006 286.033);
|
||||||
|
|
||||||
--text-primary: #ffffff;
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #bbbbbb;
|
||||||
|
|
||||||
--accent: #00d4ff;
|
--accent: #00d4ff;
|
||||||
--accent2: #7b2ff7;
|
--accent2: #7b2ff7;
|
||||||
@@ -47,13 +48,14 @@ body {
|
|||||||
|
|
||||||
.light-mode {
|
.light-mode {
|
||||||
--bg-primary: #e0e0e0;
|
--bg-primary: #e0e0e0;
|
||||||
--bg-secondary: oklch(96.7% 0.001 286.375);
|
--bg-secondary: #dadada;
|
||||||
|
|
||||||
--text-primary: #111111;
|
--text-primary: #111111;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
|
||||||
--accent: #0077ff;
|
--accent: #0077ff;
|
||||||
|
|
||||||
--muted-color: #ffffff33;
|
--muted-color: #88888833;
|
||||||
|
|
||||||
--color-base: #ffffff;
|
--color-base: #ffffff;
|
||||||
--color-inverse: #000000;
|
--color-inverse: #000000;
|
||||||
@@ -83,6 +85,10 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.text-accent {
|
.text-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user