feat: worst-scan fansub web — initial release
Posts engine (SQLite + auto-publisher via poller), public feed with clickable tags, reader, admin panel, submit/search/queue tools. BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ImageIcon } from "lucide-react"
|
||||
|
||||
export function CoverImage({
|
||||
gid,
|
||||
coverUrl,
|
||||
alt,
|
||||
className = "",
|
||||
}: {
|
||||
gid: string
|
||||
coverUrl?: string
|
||||
alt: string
|
||||
className?: string
|
||||
}) {
|
||||
const [error, setError] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const src = coverUrl || `/api/proxy/galleries/${gid}/cover`
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-[var(--surface)] ${className}`}>
|
||||
<ImageIcon className="h-6 w-6 text-[var(--muted)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
{!loaded && <div className="skeleton absolute inset-0" />}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
export function EmptyState({ message = "No hay nada aquí", icon }: { message?: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border)] py-20">
|
||||
{icon || <Inbox className="mb-3 h-10 w-10 text-[var(--muted)]" />}
|
||||
<p className="text-sm text-[var(--muted)]">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
const statuses = ["completed", "processing", "pending", "failed", ""]
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
"": "Todas",
|
||||
completed: "Completadas",
|
||||
processing: "Activas",
|
||||
pending: "Pendientes",
|
||||
failed: "Fallidas",
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
current,
|
||||
onChange,
|
||||
onRefresh,
|
||||
loading,
|
||||
}: {
|
||||
current: string
|
||||
onChange: (s: string) => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-1.5">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange(s)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
current === s
|
||||
? "bg-[var(--accent)] text-white shadow-sm"
|
||||
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] border border-transparent hover:border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{labels[s]}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="btn-ghost p-1.5"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { CoverImage } from "@/components/cover-image"
|
||||
import type { GalleryItem } from "@/lib/types"
|
||||
|
||||
export function GalleryCard({ item }: { item: GalleryItem }) {
|
||||
return (
|
||||
<div className="card card-hover group flex overflow-hidden p-0">
|
||||
<Link href={`/gallery/${item.gid}`} className="flex flex-1 gap-4">
|
||||
<CoverImage
|
||||
gid={item.gid}
|
||||
alt={item.title}
|
||||
className="h-36 w-24 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-3">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
{item.is_spanish && (
|
||||
<span className="tag-pill w-fit bg-emerald-900/30 text-emerald-300 border border-emerald-800/40">
|
||||
ESP
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||
<span>{item.pages} pág</span>
|
||||
<span
|
||||
className={`capitalize ${
|
||||
item.status === "completed"
|
||||
? "text-[var(--success)]"
|
||||
: item.status === "failed"
|
||||
? "text-[var(--error)]"
|
||||
: item.status === "processing"
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--warning)]"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<Link
|
||||
href={`/gallery/${item.gid}/read`}
|
||||
className="btn-primary text-xs py-1 px-2.5"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Leer
|
||||
</Link>
|
||||
{item.status === "failed" && (
|
||||
<span className="text-[10px] text-[var(--error)]">{item.error || "error"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GalleryCard } from "@/components/gallery-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import type { GalleryItem } from "@/lib/types"
|
||||
|
||||
export function GalleryGrid({ items }: { items: GalleryItem[] }) {
|
||||
if (!items.length) return <EmptyState message="No se encontraron galleries" />
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<GalleryCard key={item.gid} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, LogOut } from "lucide-react"
|
||||
|
||||
const links = [
|
||||
{ href: "/feed", label: "Feed", icon: LayoutDashboard },
|
||||
{ href: "/submit", label: "Enviar", icon: Send },
|
||||
{ href: "/search", label: "Buscar", icon: Search },
|
||||
{ href: "/queue", label: "Cola", icon: ListOrdered },
|
||||
{ href: "/admin/posts", label: "Posts", icon: FileText },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-56 flex-col border-r border-[var(--border)] bg-[var(--background)]">
|
||||
<div className="flex items-center gap-2.5 border-b border-[var(--border)] px-5 py-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-0.5 p-3">
|
||||
{links.map(({ href, label, icon: Icon }) => {
|
||||
const isActive = pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all ${
|
||||
isActive
|
||||
? "bg-[var(--surface)] font-medium text-[var(--foreground)]"
|
||||
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${isActive ? "text-[var(--accent)]" : ""}`} />
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--border)] p-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-all hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link"
|
||||
import type { Post } from "@/lib/db"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
|
||||
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/p/${post.slug}`}
|
||||
className="card card-hover group flex gap-4 overflow-hidden p-0"
|
||||
>
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
className="h-32 w-24 flex-shrink-0 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-4">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.artist && (
|
||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||
)}
|
||||
<div className="flex flex-1 items-end gap-2">
|
||||
{post.source && (
|
||||
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
|
||||
)}
|
||||
{post.published_at && (
|
||||
<span className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/p/${post.slug}`}
|
||||
className="card card-hover group flex flex-col overflow-hidden p-0"
|
||||
>
|
||||
<div className="aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.artist && (
|
||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto flex items-center gap-2 text-[10px] text-[var(--muted)]">
|
||||
{post.num_pages > 0 && <span>{post.num_pages} pág</span>}
|
||||
{post.published_at && <span>{formatDate(post.published_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Search, RotateCcw } from "lucide-react"
|
||||
import { CoverImage } from "@/components/cover-image"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import type { SearchResult } from "@/lib/types"
|
||||
|
||||
export function SearchForm() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [source, setSource] = useState<"nhentai" | "ehentai">("nhentai")
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [meta, setMeta] = useState<{ total: number; blocked: number; passed: number } | null>(null)
|
||||
const [queuing, setQueuing] = useState<Set<string>>(new Set())
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return
|
||||
setLoading(true)
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/proxy/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: query.trim(), source }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setResults(data.data || [])
|
||||
setMeta(data.meta || null)
|
||||
} catch {
|
||||
setResults([])
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleQueue(gid: string, url: string) {
|
||||
setQueuing((prev) => new Set(prev).add(gid))
|
||||
try {
|
||||
await fetch("/api/proxy/galleries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
} catch {
|
||||
}
|
||||
setQueuing((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(gid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleQueueAll() {
|
||||
const valid = results.filter((r) => !r.blocked)
|
||||
for (const r of valid) {
|
||||
await handleQueue(r.gid, r.url)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Buscar en nhentai o e-hentai..."
|
||||
className="input pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as "nhentai" | "ehentai")}
|
||||
className="input w-32"
|
||||
>
|
||||
<option value="nhentai">nhentai</option>
|
||||
<option value="ehentai">e-hentai</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{loading ? "..." : "Buscar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{meta && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-[var(--muted)]">{meta.total} resultados</span>
|
||||
{meta.blocked > 0 && (
|
||||
<span className="text-[var(--warning)]">{meta.blocked} bloqueados</span>
|
||||
)}
|
||||
{results.filter((r) => !r.blocked).length > 0 && (
|
||||
<button
|
||||
onClick={handleQueueAll}
|
||||
className="btn-primary ml-auto bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
Encolar todo ({results.filter((r) => !r.blocked).length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.gid}
|
||||
className={`card overflow-hidden p-3 ${
|
||||
r.blocked ? "opacity-50 border-red-900/50" : "card-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<CoverImage
|
||||
gid={r.gid}
|
||||
alt={r.title}
|
||||
className="h-28 w-20 flex-shrink-0 rounded-lg"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="truncate text-sm font-medium">{r.title}</h3>
|
||||
<span className="text-xs text-[var(--muted)]">{r.pages} páginas</span>
|
||||
{r.blocked && (
|
||||
<span className="text-xs text-[var(--error)]">{r.blocked_reason}</span>
|
||||
)}
|
||||
<div className="mt-auto flex flex-wrap gap-1">
|
||||
{r.tags.slice(0, 4).map((t) => (
|
||||
<TagBadge key={t} tag={t} />
|
||||
))}
|
||||
</div>
|
||||
{!r.blocked && (
|
||||
<button
|
||||
onClick={() => handleQueue(r.gid, r.url)}
|
||||
disabled={queuing.has(r.gid)}
|
||||
className="btn-primary mt-1 w-fit text-xs py-1 px-2.5"
|
||||
>
|
||||
{queuing.has(r.gid) ? (
|
||||
<RotateCcw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
"Encolar"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Send, CheckCircle, XCircle } from "lucide-react"
|
||||
|
||||
export function SubmitForm() {
|
||||
const [urls, setUrls] = useState("")
|
||||
const [results, setResults] = useState<{ gid: string; status: string; title?: string }[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true)
|
||||
setResults([])
|
||||
|
||||
const lines = urls.split("\n").map((l) => l.trim()).filter(Boolean)
|
||||
const batch: { gid: string; status: string }[] = []
|
||||
|
||||
for (const url of lines) {
|
||||
try {
|
||||
const res = await fetch("/api/proxy/galleries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
const data = await res.json()
|
||||
batch.push({
|
||||
gid: data?.data?.gid || "?",
|
||||
status: res.ok ? (data?.data?.status || "accepted") : (data?.error?.message || `HTTP ${res.status}`),
|
||||
})
|
||||
} catch (e) {
|
||||
batch.push({ gid: "?", status: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
setResults(batch)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
placeholder="Pegá URLs de nhentai / e-hentai (una por línea)"
|
||||
rows={6}
|
||||
className="input resize-y min-h-[140px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !urls.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "Enviando..." : "Encolar"}
|
||||
</button>
|
||||
{results.length > 0 && (
|
||||
<span className="text-sm text-[var(--muted)]">
|
||||
{results.filter((r) => r.status === "accepted" || r.status !== "?").length} de {results.length} ok
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{results.map((r, i) => {
|
||||
const ok = r.status === "accepted" || r.gid !== "?"
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 text-sm ${
|
||||
ok ? "" : "bg-[var(--error-subtle)]"
|
||||
}`}
|
||||
>
|
||||
{ok ? (
|
||||
<CheckCircle className="h-4 w-4 text-[var(--success)] flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-[var(--error)] flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-mono text-xs text-[var(--muted)]">{r.gid}</span>
|
||||
<span className={ok ? "text-[var(--muted)]" : "text-[var(--error)]"}>{r.status}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link"
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
artist: "bg-rose-900/30 text-rose-300 border-rose-800/40",
|
||||
parody: "bg-violet-900/30 text-violet-300 border-violet-800/40",
|
||||
language: "bg-emerald-900/30 text-emerald-300 border-emerald-800/40",
|
||||
category: "bg-amber-900/30 text-amber-300 border-amber-800/40",
|
||||
character: "bg-cyan-900/30 text-cyan-300 border-cyan-800/40",
|
||||
group: "bg-orange-900/30 text-orange-300 border-orange-800/40",
|
||||
tag: "bg-zinc-800 text-zinc-400 border-zinc-700/40",
|
||||
}
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
const prefix = tag.split(":")[0]
|
||||
return colors[prefix] || colors.tag
|
||||
}
|
||||
|
||||
export function TagBadge({ tag, href }: { tag: string; href?: string }) {
|
||||
const cls = `tag-pill border ${tagColor(tag)}`
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={`${cls} hover:brightness-125 transition-all`}>
|
||||
{tag}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={cls}>{tag}</span>
|
||||
}
|
||||
Reference in New Issue
Block a user