ui redesign, markdown fix + metadata and auth header
This commit is contained in:
@@ -1,25 +1,53 @@
|
||||
---
|
||||
interface Props {
|
||||
slug: string;
|
||||
date: string;
|
||||
excerpt?: string;
|
||||
tags?: string[];
|
||||
draft?: boolean;
|
||||
readingTime: number;
|
||||
formatSlug: (slug: string) => string;
|
||||
}
|
||||
|
||||
const { slug, excerpt, formatSlug } = Astro.props;
|
||||
const { slug, date, excerpt, tags = [], draft = false, readingTime, formatSlug } = Astro.props;
|
||||
|
||||
const formattedDate = new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
---
|
||||
|
||||
<a href={`/posts/${slug}`} class="group block">
|
||||
<article class="glass p-5 md:p-8 transition-all hover:scale-[1.01] hover:bg-surface0/80 active:scale-[0.99] flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl md:text-3xl font-bold text-lavender group-hover:text-mauve transition-colors mb-2 md:mb-3">
|
||||
<article class="glass p-5 md:p-8 transition-colors hover:bg-surface0/80 flex flex-col md:flex-row justify-between md:items-center gap-4 md:gap-6">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 mb-2 text-xs text-subtext0">
|
||||
<time datetime={date}>{formattedDate}</time>
|
||||
<span class="opacity-50">·</span>
|
||||
<span>{readingTime} min read</span>
|
||||
{draft && (
|
||||
<>
|
||||
<span class="opacity-50">·</span>
|
||||
<span class="text-peach uppercase tracking-wide font-semibold">Draft</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-semibold text-lavender group-hover:text-mauve transition-colors mb-2">
|
||||
{formatSlug(slug)}
|
||||
</h2>
|
||||
<p class="text-text text-sm md:text-base leading-relaxed line-clamp-3" style="color: var(--text) !important;">
|
||||
<p class="text-subtext1 text-sm md:text-base leading-relaxed line-clamp-2">
|
||||
{excerpt || `Read more about ${formatSlug(slug)}...`}
|
||||
</p>
|
||||
{tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-2 mt-3">
|
||||
{tags.map(tag => (
|
||||
<span class="text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full bg-surface0 text-subtext0 border border-surface1">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="text-mauve opacity-0 group-hover:opacity-100 transition-opacity self-end md:self-auto shrink-0 hidden md:block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md:w-8 md:h-8"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
function renderMath(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip invalid tex */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export default function PostEnhancer({ containerId }: Props) {
|
||||
useEffect(() => {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
|
||||
renderMath(el);
|
||||
el.querySelectorAll<HTMLElement>('pre code').forEach((block) => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}, [containerId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const THEMES = [
|
||||
{ value: 'mocha', label: 'Mocha' },
|
||||
@@ -19,10 +19,23 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
|
||||
}
|
||||
return defaultTheme;
|
||||
});
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const isFirst = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.className = theme;
|
||||
const html = document.documentElement;
|
||||
THEMES.forEach(t => html.classList.remove(t.value));
|
||||
html.classList.add(theme);
|
||||
localStorage.setItem('user-theme', theme);
|
||||
|
||||
if (isFirst.current) {
|
||||
isFirst.current = false;
|
||||
return;
|
||||
}
|
||||
const label = THEMES.find(t => t.value === theme)?.label ?? theme;
|
||||
setToast(`Theme: ${label}`);
|
||||
const id = setTimeout(() => setToast(null), 1200);
|
||||
return () => clearTimeout(id);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
@@ -30,15 +43,17 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-all cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
|
||||
aria-label="Theme"
|
||||
className="appearance-none bg-surface0/50 text-text border border-surface1 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-mauve transition-colors cursor-pointer hover:bg-surface0 pr-8 shadow-sm"
|
||||
>
|
||||
{THEMES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-subtext0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-9"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
{toast && <div className="toast" role="status">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function AssetManager({ mode = 'manage', onSelect }: Props) {
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{assets.map(asset => (
|
||||
<div key={asset.name} className="group relative aspect-square bg-crust rounded-xl overflow-hidden border border-white/5 transition-all hover:scale-105 shadow-lg flex flex-col">
|
||||
<div key={asset.name} className="group relative aspect-square bg-crust rounded-xl overflow-hidden border border-white/5 hover:border-mauve/40 transition-colors shadow-lg flex flex-col">
|
||||
<div className="flex-1 overflow-hidden bg-surface0/20 relative cursor-pointer">
|
||||
{isImage(asset.name) ? (
|
||||
<img src={asset.url} className="w-full h-full object-cover opacity-80 group-hover:opacity-100 transition-opacity" alt={asset.name} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, type ReactElement } from 'react';
|
||||
import { useAuth } from '../../../stores/auth';
|
||||
import { getPosts, deletePost, ApiError } from '../../../lib/api';
|
||||
import type { Post } from '../../../lib/types';
|
||||
@@ -35,7 +35,6 @@ export default function Dashboard() {
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -94,7 +93,7 @@ export default function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS: Record<string, JSX.Element> = {
|
||||
const ICONS: Record<string, ReactElement> = {
|
||||
write: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
|
||||
assets: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>,
|
||||
settings: <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>,
|
||||
|
||||
@@ -7,9 +7,7 @@ import { defaultKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { vim } from '@replit/codemirror-vim';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { closeBrackets } from '@codemirror/autocomplete';
|
||||
import { marked } from 'marked';
|
||||
import hljs from 'highlight.js';
|
||||
import katex from 'katex';
|
||||
import { renderMarkdown } from '../../../lib/markdown';
|
||||
import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api';
|
||||
import type { Asset } from '../../../lib/types';
|
||||
import AssetManager from './AssetManager';
|
||||
@@ -58,43 +56,6 @@ const narlblogTheme = EditorView.theme({
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
function renderMathInElement(element: HTMLElement) {
|
||||
const delimiters = [
|
||||
{ left: '$$', right: '$$', display: true },
|
||||
{ left: '$', right: '$', display: false },
|
||||
{ left: '\\(', right: '\\)', display: false },
|
||||
{ left: '\\[', right: '\\]', display: true },
|
||||
];
|
||||
const walk = (node: Node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'CODE' || el.tagName === 'PRE') return;
|
||||
for (const child of Array.from(el.childNodes)) walk(child);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent || '';
|
||||
for (const { left, right, display } of delimiters) {
|
||||
const idx = text.indexOf(left);
|
||||
if (idx === -1) continue;
|
||||
const end = text.indexOf(right, idx + left.length);
|
||||
if (end === -1) continue;
|
||||
const tex = text.slice(idx + left.length, end);
|
||||
try {
|
||||
const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false });
|
||||
const span = document.createElement('span');
|
||||
span.innerHTML = rendered;
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, end + right.length);
|
||||
range.deleteContents();
|
||||
range.insertNode(span);
|
||||
} catch { /* skip */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (let i = 0; i < 3; i++) walk(element);
|
||||
}
|
||||
|
||||
// Compartment for hot-swapping vim mode without recreating the editor
|
||||
const vimCompartment = new Compartment();
|
||||
|
||||
@@ -104,8 +65,12 @@ export default function Editor({ editSlug }: Props) {
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const updatePreviewRef = useRef<() => void>(() => {});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [slug, setSlug] = useState(editSlug || '');
|
||||
const [date, setDate] = useState(today);
|
||||
const [summary, setSummary] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
const [draft, setDraft] = useState(false);
|
||||
const [originalSlug, setOriginalSlug] = useState(editSlug || '');
|
||||
const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -126,19 +91,7 @@ export default function Editor({ editSlug }: Props) {
|
||||
const updatePreview = useCallback(() => {
|
||||
if (!showPreview || !viewRef.current || !previewRef.current) return;
|
||||
const content = viewRef.current.state.doc.toString();
|
||||
const result = marked.parse(content);
|
||||
if (typeof result === 'string') {
|
||||
previewRef.current.innerHTML = result;
|
||||
} else {
|
||||
result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; });
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!previewRef.current) return;
|
||||
renderMathInElement(previewRef.current);
|
||||
previewRef.current.querySelectorAll<HTMLElement>('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
});
|
||||
previewRef.current.innerHTML = renderMarkdown(content);
|
||||
}, [showPreview]);
|
||||
|
||||
useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]);
|
||||
@@ -194,6 +147,9 @@ export default function Editor({ editSlug }: Props) {
|
||||
if (!editSlug) return;
|
||||
getPost(editSlug).then(post => {
|
||||
if (post.summary) setSummary(post.summary);
|
||||
if (post.date) setDate(post.date);
|
||||
if (post.tags?.length) setTagsInput(post.tags.join(', '));
|
||||
setDraft(!!post.draft);
|
||||
if (post.content && viewRef.current) {
|
||||
viewRef.current.dispatch({
|
||||
changes: { from: 0, to: viewRef.current.state.doc.length, insert: post.content },
|
||||
@@ -257,11 +213,18 @@ export default function Editor({ editSlug }: Props) {
|
||||
showAlertMsg('Title and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
await savePost({
|
||||
slug,
|
||||
old_slug: originalSlug || null,
|
||||
date,
|
||||
summary: summary || null,
|
||||
tags,
|
||||
draft,
|
||||
content,
|
||||
});
|
||||
showAlertMsg('Post saved!', 'success');
|
||||
@@ -323,17 +286,51 @@ export default function Editor({ editSlug }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Post Title (URL identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
required
|
||||
placeholder="my-awesome-post"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
{/* Slug + Date */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px] gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Post Title (URL identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
required
|
||||
placeholder="my-awesome-post"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags + Draft */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-4 md:items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-subtext1 mb-2">Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tagsInput}
|
||||
onChange={e => setTagsInput(e.target.value)}
|
||||
placeholder="rust, astro, design"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 px-4 py-3 bg-crust border border-surface1 rounded-lg cursor-pointer hover:border-peach/40 transition-colors select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft}
|
||||
onChange={e => setDraft(e.target.checked)}
|
||||
className="accent-peach"
|
||||
/>
|
||||
<span className="text-sm font-medium text-subtext1">Draft</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { login, ApiError } from '../../../lib/api';
|
||||
import { useAuth } from '../../../stores/auth';
|
||||
|
||||
export default function Login() {
|
||||
const [value, setValue] = useState('');
|
||||
const setToken = useAuth(s => s.setToken);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const setLoggedIn = useAuth(s => s.setLoggedIn);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (value.trim()) {
|
||||
setToken(value.trim());
|
||||
const token = value.trim();
|
||||
if (!token) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(token);
|
||||
setLoggedIn(true);
|
||||
window.location.href = '/admin';
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
setError('Invalid token.');
|
||||
} else {
|
||||
setError('Login failed. Try again.');
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,12 +42,22 @@ export default function Login() {
|
||||
required
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors"
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors">
|
||||
Login
|
||||
{error && (
|
||||
<p className="text-sm text-red bg-red/10 border border-red/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full bg-mauve text-crust font-bold py-3 rounded-lg hover:bg-pink transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user