Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a76e97a67 | ||
|
|
71f79c8e02 | ||
|
|
5ee4a06654 | ||
|
|
63b286d0a4 | ||
|
|
d3f06c5c40 |
@@ -190,6 +190,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch releases from Gitea API
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESPONSE=$(curl -sfL \
|
||||
-H "Accept: application/json" \
|
||||
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
|
||||
# Validate JSON before writing — fails hard if response is not a JSON array
|
||||
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
|
||||
echo "$RESPONSE" > ui/static/releases.json
|
||||
echo "Fetched $COUNT releases"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
|
||||
61
Caddyfile
61
Caddyfile
@@ -56,6 +56,22 @@
|
||||
ticker_interval 15s
|
||||
}
|
||||
|
||||
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
|
||||
# Exposes homelab Redis over TLS for Asynq job enqueueing from the backend.
|
||||
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
|
||||
# for redis.libnovel.cc; traffic is proxied to the homelab Redis instance.
|
||||
# Requires the caddy-l4 module in the custom Caddy build.
|
||||
layer4 {
|
||||
:6380 {
|
||||
route {
|
||||
tls
|
||||
proxy {
|
||||
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(security_headers) {
|
||||
header {
|
||||
@@ -170,12 +186,31 @@
|
||||
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
|
||||
handle {
|
||||
reverse_proxy ui:3000 {
|
||||
}
|
||||
# Active health check: Caddy polls /health every 5 s and marks the
|
||||
# upstream down immediately when it fails. Combined with
|
||||
# lb_try_duration this means Watchtower container replacements
|
||||
# show the maintenance page within a few seconds instead of
|
||||
# hanging or returning a raw connection error to the browser.
|
||||
health_uri /health
|
||||
health_interval 5s
|
||||
health_timeout 2s
|
||||
health_status 200
|
||||
|
||||
# If the upstream is down, fail fast (don't retry for longer than
|
||||
# 3 s) and let Caddy's handle_errors 502/503 take over.
|
||||
lb_try_duration 3s
|
||||
}
|
||||
}
|
||||
|
||||
# ── Caddy-level error pages ───────────────────────────────────────────────
|
||||
# These fire when the upstream (backend or ui) is completely unreachable.
|
||||
# SvelteKit's own +error.svelte handles application-level errors (404, 500).
|
||||
handle_errors 404 {
|
||||
root * /srv/errors
|
||||
rewrite * /404.html
|
||||
file_server
|
||||
}
|
||||
handle_errors 502 {
|
||||
root * /srv/errors
|
||||
rewrite * /502.html
|
||||
file_server
|
||||
@@ -234,27 +269,3 @@ search.libnovel.cc {
|
||||
reverse_proxy meilisearch:7700
|
||||
}
|
||||
}
|
||||
# ── Redis TCP proxy: exposes homelab Redis over TLS for Asynq ─────────────────
|
||||
# The backend (prod) connects to rediss://redis.libnovel.cc:6380 to enqueue
|
||||
# Asynq jobs. Caddy terminates TLS (Let's Encrypt cert for redis.libnovel.cc)
|
||||
# and proxies the raw TCP stream to the homelab Redis via this reverse proxy.
|
||||
#
|
||||
# NOTE: Redis is NOT running on the prod server — it runs on the homelab
|
||||
# (192.168.0.109:6379) and is exposed to the internet via this Caddy proxy.
|
||||
# The homelab Redis is protected by REDIS_PASSWORD (requirepass).
|
||||
#
|
||||
# Caddy layer4 app handles this; requires the caddy-l4 module in the build.
|
||||
{
|
||||
layer4 {
|
||||
redis.libnovel.cc:6380 {
|
||||
route {
|
||||
tls
|
||||
proxy {
|
||||
# Homelab Redis — replace with actual homelab IP or FQDN
|
||||
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
caddy/errors/404.html
Normal file
51
caddy/errors/404.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 — Page Not Found</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">404</div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
<a href="/">Go home</a>
|
||||
</body>
|
||||
</html>
|
||||
3
ui/.gitignore
vendored
3
ui/.gitignore
vendored
@@ -21,3 +21,6 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Generated by CI at build time — do not commit
|
||||
/static/releases.json
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
const internalLinks = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
{ href: '/admin/audio', label: 'Audio' }
|
||||
{ href: '/admin/audio', label: 'Audio' },
|
||||
{ href: '/admin/changelog', label: 'Changelog' }
|
||||
];
|
||||
|
||||
const externalLinks = [
|
||||
|
||||
26
ui/src/routes/admin/changelog/+page.server.ts
Normal file
26
ui/src/routes/admin/changelog/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export interface Release {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
// releases.json is baked into the image at build time by CI.
|
||||
// SvelteKit Node adapter copies static/ → build/client/, so the file
|
||||
// lives at <cwd>/build/client/releases.json in production.
|
||||
const raw = readFileSync(join(process.cwd(), 'build', 'client', 'releases.json'), 'utf-8');
|
||||
const releases: Release[] = JSON.parse(raw);
|
||||
return { releases: releases.filter((r) => !r.draft) };
|
||||
} catch (e) {
|
||||
return { releases: [], error: String(e) };
|
||||
}
|
||||
};
|
||||
59
ui/src/routes/admin/changelog/+page.svelte
Normal file
59
ui/src/routes/admin/changelog/+page.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function fmtDate(s: string) {
|
||||
return new Date(s).toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Changelog — libnovel admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-semibold text-zinc-100 flex-1">Changelog</h1>
|
||||
<a
|
||||
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
Gitea releases
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.error}
|
||||
<p class="text-sm text-red-400">Could not load releases: {data.error}</p>
|
||||
{:else if data.releases.length === 0}
|
||||
<p class="text-sm text-zinc-500 py-8 text-center">No releases found.</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
{#each data.releases as release}
|
||||
<div class="px-5 py-4 bg-zinc-900 space-y-2">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="font-mono text-sm font-semibold text-amber-400">{release.tag_name}</span>
|
||||
{#if release.name && release.name !== release.tag_name}
|
||||
<span class="text-sm text-zinc-300">{release.name}</span>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">pre-release</span>
|
||||
{/if}
|
||||
<span class="text-xs text-zinc-600 ml-auto">{fmtDate(release.published_at)}</span>
|
||||
</div>
|
||||
{#if release.body.trim()}
|
||||
<p class="text-sm text-zinc-400 leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -231,131 +231,110 @@
|
||||
<title>Scrape tasks — libnovel admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
Job status:
|
||||
{#if running}
|
||||
<span class="text-amber-400 font-medium animate-pulse">Running</span>
|
||||
{:else}
|
||||
<span class="text-green-400 font-medium">Idle</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-xl font-semibold text-zinc-100 flex-1">Scrape</h1>
|
||||
<span class="text-xs {running ? 'text-amber-400 animate-pulse' : 'text-green-500'}">
|
||||
● {running ? 'Running' : 'Idle'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Scrape controls -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Compact controls -->
|
||||
<div class="divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<!-- Full catalogue -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 px-4 py-3 bg-zinc-900">
|
||||
<span class="text-sm text-zinc-400 w-36 shrink-0">Full catalogue</span>
|
||||
<button
|
||||
onclick={triggerCatalogueScrape}
|
||||
disabled={running || cataloguing}
|
||||
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50"
|
||||
class="px-3 py-1.5 rounded-md bg-amber-600 text-zinc-900 font-semibold text-xs hover:bg-amber-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'}
|
||||
{cataloguing ? 'Queuing…' : running ? 'Running…' : 'Start scrape'}
|
||||
</button>
|
||||
{#if catalogueError}
|
||||
<p class="text-sm text-red-400">{catalogueError}</p>
|
||||
{/if}
|
||||
{#if catalogueError}<span class="text-xs text-red-400">{catalogueError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Single book -->
|
||||
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="flex-1 min-w-0 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerBookScrape(scrapeUrl)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? 'Queuing…' : 'Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
{#if scrapeError}
|
||||
<p class="text-sm text-red-400">{scrapeError}</p>
|
||||
{/if}
|
||||
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-zinc-900">
|
||||
<span class="text-sm text-zinc-400 w-36 shrink-0">Single book</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerBookScrape(scrapeUrl)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-zinc-700 text-zinc-100 font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? 'Queuing…' : 'Scrape'}
|
||||
</button>
|
||||
{#if scrapeError}<span class="text-xs text-red-400">{scrapeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div id="range-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape chapter range</h2>
|
||||
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-zinc-900 flex-wrap">
|
||||
<span class="text-sm text-zinc-400 w-36 shrink-0">Chapter range</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={rangeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeFrom}
|
||||
min="1"
|
||||
placeholder="From ch."
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeTo}
|
||||
min="1"
|
||||
placeholder="To ch. (opt)"
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={triggerRangeScrape}
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{ranging ? 'Queuing…' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
{#if rangeError}
|
||||
<p class="text-sm text-red-400">{rangeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-scrape genre links -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Quick genre refresh</h2>
|
||||
<p class="text-xs text-zinc-500">Paste one of these into the single-book scraper to re-index a genre, or use them as starting points for range scrapes.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
onclick={() => { scrapeUrl = qs.url; }}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 border border-zinc-600 hover:border-amber-400/60 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{qs.label}
|
||||
</button>
|
||||
{/each}
|
||||
<a
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700/50 text-zinc-400 border border-zinc-600/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeFrom}
|
||||
min="1"
|
||||
placeholder="From"
|
||||
class="w-20 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeTo}
|
||||
min="1"
|
||||
placeholder="To"
|
||||
class="w-20 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-1.5 text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={triggerRangeScrape}
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-zinc-700 text-zinc-100 font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Browse novelfire.net ↗
|
||||
</a>
|
||||
{ranging ? 'Queuing…' : 'Go'}
|
||||
</button>
|
||||
{#if rangeError}<span class="text-xs text-red-400 w-full pl-40">{rangeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick genre chips -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-zinc-900 flex-wrap">
|
||||
<span class="text-sm text-zinc-400 w-36 shrink-0">Quick genres</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
onclick={() => { scrapeUrl = qs.url; }}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:border-amber-400/50 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{qs.label}
|
||||
</button>
|
||||
{/each}
|
||||
<a
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-2.5 py-1 rounded text-xs font-medium text-zinc-500 border border-zinc-700/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
|
||||
>
|
||||
novelfire.net ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks table -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 flex-1">Task history</h2>
|
||||
<h2 class="text-sm font-semibold text-zinc-400 flex-1 uppercase tracking-widest">Task history</h2>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
|
||||
Reference in New Issue
Block a user