diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8796e3e..9d8086a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useDeferredValue, useRef } from 'react'; import Link from 'next/link'; import { FiSave, @@ -16,10 +16,14 @@ import { FiList, FiTrash2, FiChevronRight, + FiChevronDown, FiMessageSquare, FiCornerUpLeft, FiBarChart2, - FiTrendingUp + FiTrendingUp, + FiBookOpen, + FiTool, + FiUpload } from "react-icons/fi"; import { Button } from "@/components/ui/button"; @@ -28,8 +32,11 @@ import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { homeToolIconOptions, HomeToolIcon } from "@/lib/home-module-types"; +import MarkdownContent from "@/components/markdown-content"; +import { renderMarkdownPreviewHtml } from "@/lib/markdown-preview"; -type AdminType = 'dashboard' | 'post' | 'daily' | 'moment' | 'comment'; +type AdminType = 'dashboard' | 'homepage-tool' | 'homepage-book' | 'post' | 'daily' | 'moment' | 'comment'; type PostData = { @@ -54,6 +61,23 @@ type MomentData = { content: string; }; +type ToolData = { + name: string; + description: string; + link: string; + icon: HomeToolIcon; + sortOrder: number; + enabled: boolean; +}; + +type BookData = { + title: string; + cover: string; + hoverText: string; + sortOrder: number; + enabled: boolean; +}; + type AdminItem = { filename: string; date: string; @@ -67,6 +91,14 @@ type AdminItem = { contact?: string; articleTitle?: string; created_at?: string; + id?: number; + name?: string; + link?: string; + icon?: HomeToolIcon; + sortOrder?: number; + enabled?: boolean; + cover?: string; + hoverText?: string; }; type StatusMessage = { text: string; isError: boolean }; @@ -75,24 +107,113 @@ type DashboardData = { posts: AdminItem[]; daily: AdminItem[]; moments: AdminItem[]; + analytics: AnalyticsSummary; }; type ChartRange = '7d' | '30d'; -const visitorSeries: Record = { - '7d': [18, 24, 21, 32, 27, 15, 17], - '30d': [6, 9, 7, 12, 15, 10, 18, 22, 19, 16, 28, 24, 31, 35, 29, 25, 20, 26, 33, 38, 34, 41, 37, 44, 39, 31, 27, 25, 18, 17], +type AnalyticsPoint = { + date: string; + views: number; + visitors: number; }; -const viewSeries: Record = { - '7d': [72, 95, 88, 136, 121, 148, 167], - '30d': [31, 44, 38, 52, 65, 58, 73, 90, 86, 78, 96, 104, 118, 132, 126, 119, 101, 116, 135, 151, 148, 163, 156, 174, 168, 143, 132, 128, 118, 167], +type AnalyticsSummary = { + totalViews: number; + todayViews: number; + uniqueVisitors: number; + series: Record; }; -const totalVisits = 1475; +const buildEmptyAnalytics = (): AnalyticsSummary => ({ + totalViews: 0, + todayViews: 0, + uniqueVisitors: 0, + series: { + '7d': buildDateRange(7).map((date) => ({ date, views: 0, visitors: 0 })), + '30d': buildDateRange(30).map((date) => ({ date, views: 0, visitors: 0 })), + }, +}); + +const buildDateRange = (days: number) => { + const dates: string[] = []; + const now = new Date(); + + for (let offset = days - 1; offset >= 0; offset -= 1) { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + date.setUTCDate(date.getUTCDate() - offset); + dates.push(date.toISOString().slice(0, 10)); + } + + return dates; +}; const formatMetric = (value: number) => new Intl.NumberFormat('en-US').format(value); +const isHomePageType = (value: AdminType) => value === 'homepage-tool' || value === 'homepage-book'; + +const getHomeItemType = (value: AdminType) => value === 'homepage-book' ? 'book' : 'tool'; + +const getTypeLabel = (value: AdminType) => { + const labels: Record = { + dashboard: 'Dashboard', + 'homepage-tool': 'Tools', + 'homepage-book': 'Books', + post: 'Post', + daily: 'Daily', + moment: 'Moment', + comment: 'Comment', + }; + + return labels[value]; +}; + +const createDefaultToolData = (): ToolData => ({ + name: '', + description: '', + link: '', + icon: 'file', + sortOrder: 0, + enabled: true, +}); + +const createDefaultBookData = (): BookData => ({ + title: '', + cover: '', + hoverText: '', + sortOrder: 0, + enabled: true, +}); + +type UploadFolder = 'posts' | 'daily' | 'moments' | 'books'; + +const sidebarMotion = 'duration-500 ease-[cubic-bezier(.22,1,.36,1)]'; +const navItemMotion = `transition-[background-color,border-color,color,padding,transform] ${sidebarMotion}`; +const sidebarTextMotion = `overflow-hidden whitespace-nowrap transition-[opacity,transform,max-width] ${sidebarMotion}`; + +const getListTitle = (item: AdminItem, value: AdminType) => { + if (value === 'daily') return item.date; + if (value === 'comment') return item.content; + if (value === 'homepage-tool') return item.name || item.title; + return item.title; +}; + +const getListDescription = (item: AdminItem, value: AdminType) => { + if (value === 'post') return item.description || '...'; + if (value === 'comment') return ''; + if (value === 'homepage-tool') return item.description || item.link || '...'; + if (value === 'homepage-book') return item.hoverText || item.cover || '...'; + return item.content ? item.content.substring(0, 60) : '...'; +}; + +const getListMeta = (item: AdminItem, value: AdminType) => { + if (isHomePageType(value)) { + return `SORT:${item.sortOrder ?? 0} / ${item.enabled === false ? 'OFF' : 'ON'}`; + } + + return item.date || item.created_at; +}; + const getItemTextLength = (item: AdminItem) => { return [item.title, item.description, item.content] .filter(Boolean) @@ -207,7 +328,7 @@ function DashboardView({ { label: '文章数', value: postCount, meta: '文章档案', icon: }, { label: '总字数', value: totalWords, meta: 'Markdown 字符', icon: }, { label: '总内容数', value: totalContent, meta: '文章 + 日常 + 瞬间', icon: }, - { label: '总访问量', value: totalVisits, meta: '今日新增 167 次', icon: }, + { label: '总访问量', value: data.analytics.totalViews, meta: `今日新增 ${formatMetric(data.analytics.todayViews)} 次`, icon: }, ]; return ( @@ -233,14 +354,14 @@ function DashboardView({ title="访客趋势图" range={visitorRange} onRangeChange={setVisitorRange} - values={visitorSeries[visitorRange]} + values={data.analytics.series[visitorRange].map((point) => point.visitors)} unit="人" /> point.views)} unit="次" /> @@ -248,66 +369,19 @@ function DashboardView({ ); } -const escapeHtml = (value: string) => { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -}; - -const renderInlineMarkdown = (value: string) => { - return escapeHtml(value) - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); -}; - -const renderMarkdownPreview = (markdown: string) => { - if (!markdown.trim()) { - return '

Preview_Waiting_For_Input

'; - } - - const blocks = markdown.split(/\n{2,}/); - - return blocks.map((block) => { - const trimmed = block.trim(); - if (!trimmed) return ''; - - if (trimmed.startsWith('```')) { - const code = trimmed.replace(/^```[a-zA-Z0-9_-]*\n?/, '').replace(/```$/, ''); - return `
${escapeHtml(code)}
`; - } - - if (trimmed.startsWith('# ')) return `

${renderInlineMarkdown(trimmed.slice(2))}

`; - if (trimmed.startsWith('## ')) return `

${renderInlineMarkdown(trimmed.slice(3))}

`; - if (trimmed.startsWith('### ')) return `

${renderInlineMarkdown(trimmed.slice(4))}

`; - - if (trimmed.startsWith('>')) { - const quote = trimmed.split('\n').map(line => line.replace(/^>\s?/, '')).join('
'); - return `

${renderInlineMarkdown(quote)}

`; - } - - if (/^- /.test(trimmed)) { - const items = trimmed.split('\n').map(line => `
  • ${renderInlineMarkdown(line.replace(/^- /, ''))}
  • `).join(''); - return `
      ${items}
    `; - } - - return `

    ${renderInlineMarkdown(trimmed).replace(/\n/g, '
    ')}

    `; - }).join(''); -}; - export default function AdminPage() { const postContentRef = useRef(null); + const postPreviewRef = useRef(null); const [isAuthorized, setIsAuthorized] = useState(false); const [password, setPassword] = useState(''); const [authError, setAuthError] = useState(false); const [checkingAuth, setCheckingAuth] = useState(true); const [type, setType] = useState('dashboard'); const [loading, setLoading] = useState(false); + const [uploadingPostImage, setUploadingPostImage] = useState(false); + const [uploadingDailyImage, setUploadingDailyImage] = useState(false); + const [uploadingMomentImage, setUploadingMomentImage] = useState(false); + const [uploadingCover, setUploadingCover] = useState(false); const [listLoading, setListLoading] = useState(false); const [message, setMessage] = useState(null); @@ -317,16 +391,21 @@ export default function AdminPage() { const defaultSlug = today.replace(/-/g, '').slice(2); // YYMMDD const [postData, setPostData] = useState({ title: '', date: today, description: '', content: '', slug: defaultSlug, category: '' }); + const deferredPostContent = useDeferredValue(postData.content); + const [postPreviewHtml, setPostPreviewHtml] = useState(''); const [isSlugModified, setIsSlugModified] = useState(false); const [dailyData, setDailyData] = useState({ date: today, imageUrl: '', content: '' }); const [momentData, setMomentData] = useState({ title: '', date: today, imageUrl: '', content: '' }); + const [toolData, setToolData] = useState(createDefaultToolData()); + const [bookData, setBookData] = useState(createDefaultBookData()); const [existingPosts, setExistingPosts] = useState([]); - const [dashboardData, setDashboardData] = useState({ posts: [], daily: [], moments: [] }); + const [dashboardData, setDashboardData] = useState({ posts: [], daily: [], moments: [], analytics: buildEmptyAnalytics() }); const [dashboardLoading, setDashboardLoading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [currentFilename, setCurrentFilename] = useState(null); const [viewMode, setViewMode] = useState<'edit' | 'list'>('list'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); + const [isHomePageOpen, setIsHomePageOpen] = useState(false); // Delete Confirmation State const [deleteTargetId, setDeleteTargetId] = useState(null); @@ -375,27 +454,32 @@ export default function AdminPage() { try { const adminKey = token || localStorage.getItem('admin_key') || ''; setDashboardLoading(true); - const [postsRes, dailyRes, momentsRes] = await Promise.all( - ['post', 'daily', 'moment'].map((targetType) => fetch(`/api/admin/list?type=${targetType}`, { + const [postsRes, dailyRes, momentsRes, analyticsRes] = await Promise.all([ + ...(['post', 'daily', 'moment'] as const).map((targetType) => fetch(`/api/admin/list?type=${targetType}`, { + headers: { 'Authorization': adminKey } + })), + fetch('/api/admin/analytics', { headers: { 'Authorization': adminKey } - })) - ); + }), + ]); - if ([postsRes, dailyRes, momentsRes].some((res) => res.status === 401)) { + if ([postsRes, dailyRes, momentsRes, analyticsRes].some((res) => res.status === 401)) { setIsAuthorized(false); return; } - const [postsData, dailyDataRes, momentsData] = await Promise.all([ + const [postsData, dailyDataRes, momentsData, analyticsData] = await Promise.all([ postsRes.json() as Promise<{ items: AdminItem[] }>, dailyRes.json() as Promise<{ items: AdminItem[] }>, momentsRes.json() as Promise<{ items: AdminItem[] }>, + analyticsRes.ok ? analyticsRes.json() as Promise : Promise.resolve(buildEmptyAnalytics()), ]); setDashboardData({ posts: postsData.items || [], daily: dailyDataRes.items || [], moments: momentsData.items || [], + analytics: analyticsData || buildEmptyAnalytics(), }); } catch (error) { console.error('Failed to fetch dashboard data', error); @@ -413,7 +497,10 @@ export default function AdminPage() { try { const adminKey = token || localStorage.getItem('admin_key') || ''; setListLoading(true); - const res = await fetch(`/api/admin/list?type=${type}`, { + const listUrl = isHomePageType(type) + ? `/api/admin/home-items?type=${getHomeItemType(type)}` + : `/api/admin/list?type=${type}`; + const res = await fetch(listUrl, { headers: { 'Authorization': adminKey } }); if (res.ok) { @@ -431,12 +518,22 @@ export default function AdminPage() { }, [fetchDashboardData, type]); useEffect(() => { - const key = localStorage.getItem('admin_key'); - if (key) { - setIsAuthorized(true); - fetchPosts(key); - } - setCheckingAuth(false); + let cancelled = false; + + queueMicrotask(() => { + if (cancelled) return; + + const key = localStorage.getItem('admin_key'); + if (key) { + setIsAuthorized(true); + fetchPosts(key); + } + setCheckingAuth(false); + }); + + return () => { + cancelled = true; + }; }, [fetchPosts]); useEffect(() => { @@ -445,6 +542,27 @@ export default function AdminPage() { } }, [viewMode, isAuthorized, fetchPosts]); + useEffect(() => { + if (type !== 'post' || viewMode !== 'edit') return; + + let cancelled = false; + + renderMarkdownPreviewHtml(deferredPostContent) + .then((html) => { + if (!cancelled) setPostPreviewHtml(html); + }) + .catch((error) => { + console.error('Failed to render markdown preview', error); + if (!cancelled) { + setPostPreviewHtml('

    Preview_Render_Error

    '); + } + }); + + return () => { + cancelled = true; + }; + }, [deferredPostContent, type, viewMode]); + const handleEditPost = (item: AdminItem) => { if (type === 'post') { setPostData({ @@ -468,6 +586,23 @@ export default function AdminPage() { imageUrl: item.imageUrl || '', content: item.content || '' }); + } else if (type === 'homepage-tool') { + setToolData({ + name: item.name || item.title || '', + description: item.description || '', + link: item.link || '', + icon: item.icon || 'file', + sortOrder: Number(item.sortOrder) || 0, + enabled: item.enabled !== false, + }); + } else if (type === 'homepage-book') { + setBookData({ + title: item.title || '', + cover: item.cover || '', + hoverText: item.hoverText || '', + sortOrder: Number(item.sortOrder) || 0, + enabled: item.enabled !== false, + }); } setCurrentFilename(item.filename); @@ -496,6 +631,16 @@ export default function AdminPage() { setIsEditing(false); setCurrentFilename(null); setViewMode('edit'); + } else if (type === 'homepage-tool') { + setToolData(createDefaultToolData()); + setIsEditing(false); + setCurrentFilename(null); + setViewMode('edit'); + } else if (type === 'homepage-book') { + setBookData(createDefaultBookData()); + setIsEditing(false); + setCurrentFilename(null); + setViewMode('edit'); } }; @@ -503,6 +648,18 @@ export default function AdminPage() { setPostData(prev => ({ ...prev, content })); }; + const syncPostPreviewScroll = (event: React.UIEvent) => { + const source = event.currentTarget; + const preview = postPreviewRef.current; + if (!preview) return; + + const sourceScrollable = source.scrollHeight - source.clientHeight; + const previewScrollable = preview.scrollHeight - preview.clientHeight; + if (sourceScrollable <= 0 || previewScrollable <= 0) return; + + preview.scrollTop = (source.scrollTop / sourceScrollable) * previewScrollable; + }; + const insertPostMarkdown = (before: string, after = '', placeholder = 'text') => { const textarea = postContentRef.current; const current = postData.content; @@ -533,16 +690,19 @@ export default function AdminPage() { } else if (type === 'daily') data = dailyData; else if (type === 'moment') data = momentData; + else if (type === 'homepage-tool') data = { ...toolData, id: currentFilename ? Number(currentFilename) : undefined }; + else if (type === 'homepage-book') data = { ...bookData, id: currentFilename ? Number(currentFilename) : undefined }; try { const adminKey = localStorage.getItem('admin_key') || ''; - const res = await fetch('/api/admin/save', { + const isHomeItem = isHomePageType(type); + const res = await fetch(isHomeItem ? '/api/admin/home-items' : '/api/admin/save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': adminKey }, - body: JSON.stringify({ type, data }), + body: JSON.stringify({ type: isHomeItem ? getHomeItemType(type) : type, data }), }); if (res.status === 401) { @@ -561,6 +721,20 @@ export default function AdminPage() { } else if (type === 'daily') setDailyData({ date: today, imageUrl: '', content: '' }); else if (type === 'moment') setMomentData({ title: '', date: today, imageUrl: '', content: '' }); + else if (type === 'homepage-tool') { + setToolData(createDefaultToolData()); + setCurrentFilename(null); + setIsEditing(false); + setViewMode('list'); + fetchPosts(); + } + else if (type === 'homepage-book') { + setBookData(createDefaultBookData()); + setCurrentFilename(null); + setIsEditing(false); + setViewMode('list'); + fetchPosts(); + } setTimeout(() => setMessage(null), 3000); } else { @@ -573,6 +747,126 @@ export default function AdminPage() { } }; + const uploadImageAsset = async (file: File, folder: UploadFolder) => { + const adminKey = localStorage.getItem('admin_key') || ''; + const formData = new FormData(); + formData.append('file', file); + formData.append('folder', folder); + + const res = await fetch('/api/admin/upload', { + method: 'POST', + headers: { 'Authorization': adminKey }, + body: formData, + }); + + if (res.status === 401) { + localStorage.removeItem('admin_key'); + setIsAuthorized(false); + setMessage({ text: 'PERMISSION_DENIED: AUTH_EXPIRED', isError: true }); + return null; + } + + const result = await res.json() as { success?: boolean; url?: string; error?: string }; + if (!res.ok || !result.success || !result.url) { + setMessage({ text: `ERROR: ${result.error || 'Upload failed'}`, isError: true }); + return null; + } + + return result.url; + }; + + const handlePostImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + setUploadingPostImage(true); + setMessage(null); + + try { + const url = await uploadImageAsset(file, 'posts'); + if (!url) return; + + insertPostMarkdown('![', `](${url})`, file.name.replace(/\.[^.]+$/, '') || 'image'); + setMessage({ text: 'SUCCESS: IMAGE_INSERTED', isError: false }); + setTimeout(() => setMessage(null), 3000); + } catch (error) { + console.error('Post image upload error:', error); + setMessage({ text: 'NETWORK ERROR: UPLOAD_FAILED', isError: true }); + } finally { + setUploadingPostImage(false); + } + }; + + const handleDailyImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + setUploadingDailyImage(true); + setMessage(null); + + try { + const url = await uploadImageAsset(file, 'daily'); + if (!url) return; + + setDailyData((current) => ({ ...current, imageUrl: url })); + setMessage({ text: 'SUCCESS: IMAGE_UPLOADED', isError: false }); + setTimeout(() => setMessage(null), 3000); + } catch (error) { + console.error('Daily image upload error:', error); + setMessage({ text: 'NETWORK ERROR: UPLOAD_FAILED', isError: true }); + } finally { + setUploadingDailyImage(false); + } + }; + + const handleMomentImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + setUploadingMomentImage(true); + setMessage(null); + + try { + const url = await uploadImageAsset(file, 'moments'); + if (!url) return; + + setMomentData((current) => ({ ...current, imageUrl: url })); + setMessage({ text: 'SUCCESS: IMAGE_UPLOADED', isError: false }); + setTimeout(() => setMessage(null), 3000); + } catch (error) { + console.error('Moment image upload error:', error); + setMessage({ text: 'NETWORK ERROR: UPLOAD_FAILED', isError: true }); + } finally { + setUploadingMomentImage(false); + } + }; + + const handleBookCoverUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + setUploadingCover(true); + setMessage(null); + + try { + const url = await uploadImageAsset(file, 'books'); + if (!url) return; + + setBookData((current) => ({ ...current, cover: url })); + setMessage({ text: 'SUCCESS: COVER_UPLOADED', isError: false }); + setTimeout(() => setMessage(null), 3000); + } catch (error) { + console.error('Cover upload error:', error); + setMessage({ text: 'NETWORK ERROR: UPLOAD_FAILED', isError: true }); + } finally { + setUploadingCover(false); + } + }; + const handleReply = (e: React.MouseEvent, item: AdminItem) => { e.stopPropagation(); setReplyTarget(item); @@ -624,16 +918,16 @@ export default function AdminPage() { setLoading(true); try { const adminKey = localStorage.getItem('admin_key') || ''; - const res = await fetch('/api/admin/delete', { + const isHomeItem = isHomePageType(targetType); + const res = await fetch(isHomeItem ? '/api/admin/home-items' : '/api/admin/delete', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'Authorization': adminKey }, - body: JSON.stringify({ - type: targetType, - filename: filename - }) + body: JSON.stringify(isHomeItem + ? { type: getHomeItemType(targetType), id: Number(filename) } + : { type: targetType, filename }) }); if (res.ok) { @@ -713,31 +1007,95 @@ export default function AdminPage() {
    @@ -964,6 +1322,17 @@ export default function AdminPage() { + @@ -978,14 +1347,16 @@ export default function AdminPage() { rows={25} value={postData.content} onChange={(e) => updatePostContent(e.target.value)} - className="flex min-h-[560px] w-full resize-y border-0 bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-300 outline-none transition-colors placeholder:text-neutral-700 focus:bg-neutral-900/40" + onScroll={syncPostPreviewScroll} + className="flex h-[560px] w-full resize-none border-0 bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-300 outline-none transition-colors placeholder:text-neutral-700 focus:bg-neutral-900/40" /> -
    +
    Rendered_Preview
    -
    @@ -1002,7 +1373,20 @@ export default function AdminPage() {
    - setDailyData({ ...dailyData, imageUrl: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 font-mono text-[10px] text-neutral-400 focus:bg-neutral-900" placeholder="https://..." /> +
    + setDailyData({ ...dailyData, imageUrl: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 font-mono text-[10px] text-neutral-400 focus:bg-neutral-900" placeholder="https://..." /> + +
    @@ -1025,7 +1409,20 @@ export default function AdminPage() {
    - setMomentData({ ...momentData, imageUrl: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 font-mono text-[10px] text-neutral-400 focus:bg-neutral-900" /> +
    + setMomentData({ ...momentData, imageUrl: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 font-mono text-[10px] text-neutral-400 focus:bg-neutral-900" placeholder="https://..." /> + +
    @@ -1034,6 +1431,101 @@ export default function AdminPage() {
    )} + {type === 'homepage-tool' && ( +
    +
    +
    + + setToolData({ ...toolData, name: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 text-xs focus:bg-neutral-900 text-white" /> +
    +
    + + +
    +
    +
    + + setToolData({ ...toolData, description: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 text-xs focus:bg-neutral-900 text-neutral-300" /> +
    +
    + + setToolData({ ...toolData, link: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 font-mono text-[10px] text-neutral-400 focus:bg-neutral-900" placeholder="https://..." /> +
    +
    +
    + + setToolData({ ...toolData, sortOrder: Number(e.target.value) })} className="bg-neutral-900/50 border-neutral-800 h-9 text-xs focus:bg-neutral-900 text-white" /> +
    + +
    +
    + )} + + {type === 'homepage-book' && ( +
    +
    + + setBookData({ ...bookData, title: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 text-xs focus:bg-neutral-900 text-white" /> +
    +
    + +
    + setBookData({ ...bookData, cover: e.target.value })} className="bg-neutral-900/50 border-neutral-800 h-9 font-mono text-[10px] text-neutral-400 focus:bg-neutral-900" placeholder="https://..." /> + +
    +

    R2 upload limit: 8MB / JPG PNG WEBP GIF

    +
    +
    + +