Compare commits
3 Commits
v4.5.0
...
v3-cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51adf0e7a5 | ||
|
|
5bc69ad9ce | ||
|
|
c09d9d0ca2 |
@@ -559,6 +559,11 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
|
|||||||
go func() {
|
go func() {
|
||||||
defer deregisterCancelJob(jobID)
|
defer deregisterCancelJob(jobID)
|
||||||
defer jobCancel()
|
defer jobCancel()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("admin: image-gen goroutine panic", "job_id", jobID, "recover", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if jobCtx.Err() != nil {
|
if jobCtx.Err() != nil {
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
@@ -625,13 +630,19 @@ func (s *Server) handleAdminImageGenAsync(w http.ResponseWriter, r *http.Request
|
|||||||
Guidance: capturedReq.Guidance,
|
Guidance: capturedReq.Guidance,
|
||||||
})
|
})
|
||||||
|
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
"status": string(domain.TaskStatusDone),
|
"status": string(domain.TaskStatusDone),
|
||||||
"items_done": 1,
|
"items_done": 1,
|
||||||
"items_total": 1,
|
"items_total": 1,
|
||||||
"payload": string(resultJSON),
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
"finished": time.Now().Format(time.RFC3339),
|
}); err != nil {
|
||||||
})
|
logger.Error("admin: image-gen failed to mark job done", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"payload": string(resultJSON),
|
||||||
|
}); err != nil {
|
||||||
|
logger.Error("admin: image-gen failed to write job payload", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("admin: image-gen async done",
|
logger.Info("admin: image-gen async done",
|
||||||
"job_id", jobID, "slug", capturedReq.Slug,
|
"job_id", jobID, "slug", capturedReq.Slug,
|
||||||
|
|||||||
@@ -516,6 +516,11 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
|
|||||||
go func() {
|
go func() {
|
||||||
defer deregisterCancelJob(jobID)
|
defer deregisterCancelJob(jobID)
|
||||||
defer jobCancel()
|
defer jobCancel()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("admin: text-gen chapter-names goroutine panic", "job_id", jobID, "recover", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var allResults []proposedChapterTitle
|
var allResults []proposedChapterTitle
|
||||||
chaptersDone := 0
|
chaptersDone := 0
|
||||||
@@ -574,12 +579,18 @@ func (s *Server) handleAdminTextGenChapterNamesAsync(w http.ResponseWriter, r *h
|
|||||||
if jobCtx.Err() != nil {
|
if jobCtx.Err() != nil {
|
||||||
status = domain.TaskStatusCancelled
|
status = domain.TaskStatusCancelled
|
||||||
}
|
}
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
"status": string(status),
|
"status": string(status),
|
||||||
"items_done": chaptersDone,
|
"items_done": chaptersDone,
|
||||||
"finished": time.Now().Format(time.RFC3339),
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
"payload": finalPayload,
|
}); err != nil {
|
||||||
})
|
logger.Error("admin: text-gen chapter-names failed to mark job done", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"payload": finalPayload,
|
||||||
|
}); err != nil {
|
||||||
|
logger.Error("admin: text-gen chapter-names failed to write job payload", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
logger.Info("admin: text-gen chapter-names async done",
|
logger.Info("admin: text-gen chapter-names async done",
|
||||||
"job_id", jobID, "slug", capturedSlug,
|
"job_id", jobID, "slug", capturedSlug,
|
||||||
"results", len(allResults), "status", string(status))
|
"results", len(allResults), "status", string(status))
|
||||||
@@ -902,6 +913,11 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
|
|||||||
go func() {
|
go func() {
|
||||||
defer deregisterCancelJob(jobID)
|
defer deregisterCancelJob(jobID)
|
||||||
defer jobCancel()
|
defer jobCancel()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Error("admin: text-gen description goroutine panic", "job_id", jobID, "recover", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if jobCtx.Err() != nil {
|
if jobCtx.Err() != nil {
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
@@ -955,13 +971,19 @@ func (s *Server) handleAdminTextGenDescriptionAsync(w http.ResponseWriter, r *ht
|
|||||||
NewDescription: strings.TrimSpace(newDesc),
|
NewDescription: strings.TrimSpace(newDesc),
|
||||||
})
|
})
|
||||||
|
|
||||||
_ = store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
"status": string(domain.TaskStatusDone),
|
"status": string(domain.TaskStatusDone),
|
||||||
"items_done": 1,
|
"items_done": 1,
|
||||||
"items_total": 1,
|
"items_total": 1,
|
||||||
"payload": string(resultJSON),
|
|
||||||
"finished": time.Now().Format(time.RFC3339),
|
"finished": time.Now().Format(time.RFC3339),
|
||||||
})
|
}); err != nil {
|
||||||
|
logger.Error("admin: text-gen description failed to mark job done", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||||
|
"payload": string(resultJSON),
|
||||||
|
}); err != nil {
|
||||||
|
logger.Error("admin: text-gen description failed to write job payload", "job_id", jobID, "err", err)
|
||||||
|
}
|
||||||
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
|
logger.Info("admin: text-gen description async done", "job_id", jobID, "slug", capturedMeta.Slug)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -237,14 +237,18 @@ func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (stri
|
|||||||
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
|
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
|
||||||
return text, nil
|
return text, nil
|
||||||
}
|
}
|
||||||
// Fall back: array of objects with a "generated_text" field.
|
// Fall back: array of objects with a "generated_text" field
|
||||||
|
// (older CF AI models return [{"generated_text":"..."}]).
|
||||||
var arr []struct {
|
var arr []struct {
|
||||||
GeneratedText string `json:"generated_text"`
|
GeneratedText string `json:"generated_text"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
|
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 && arr[0].GeneratedText != "" {
|
||||||
return arr[0].GeneratedText, nil
|
return arr[0].GeneratedText, nil
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
|
// Final fallback: model returned the result directly as a JSON value
|
||||||
|
// (e.g. Llama 4 Scout returns [{"number":1,"title":"..."},...] directly).
|
||||||
|
// Return the raw JSON bytes as a string so callers can parse it themselves.
|
||||||
|
return string(wrapper.Result.Response), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Models returns all supported text generation model metadata.
|
// Models returns all supported text generation model metadata.
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export const GET: RequestHandler = async ({ params }) => {
|
|||||||
const task = await getScrapingTask(id).catch(() => null);
|
const task = await getScrapingTask(id).catch(() => null);
|
||||||
if (!task) throw error(404, 'Task not found');
|
if (!task) throw error(404, 'Task not found');
|
||||||
|
|
||||||
return json({ id: task.id, status: task.status, error_message: task.error_message ?? '' });
|
return json({ id: task.id, status: task.status, chapters_scraped: task.chapters_scraped ?? 0, error_message: task.error_message ?? '' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -167,17 +167,24 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
|
|
||||||
// ── Original content path ──────────────────────────────────────────────
|
// ── Original content path ──────────────────────────────────────────────
|
||||||
let html = '';
|
let html = '';
|
||||||
|
let contentMissing = false;
|
||||||
try {
|
try {
|
||||||
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
|
log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
|
||||||
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
|
if (res.status === 404) {
|
||||||
|
// Content file missing from storage — render the page shell with an
|
||||||
|
// empty body so admins can trigger a re-scrape from the page itself.
|
||||||
|
contentMissing = true;
|
||||||
|
} else {
|
||||||
|
error(502, 'Could not fetch chapter content');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const markdown = await res.text();
|
||||||
|
html = await marked(markdown);
|
||||||
}
|
}
|
||||||
const markdown = await res.text();
|
|
||||||
html = await marked(markdown);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && 'status' in e) throw e;
|
if (e instanceof Error && 'status' in e) throw e;
|
||||||
// Don't hard-fail — show empty content with error message
|
|
||||||
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
|
||||||
error(502, 'Could not fetch chapter content');
|
error(502, 'Could not fetch chapter content');
|
||||||
}
|
}
|
||||||
@@ -202,9 +209,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', source_url: book.source_url ?? '' },
|
||||||
chapter: chapterIdx,
|
chapter: chapterIdx,
|
||||||
html,
|
html,
|
||||||
|
contentMissing,
|
||||||
voices,
|
voices,
|
||||||
prev: prevChapter ? prevChapter.number : null,
|
prev: prevChapter ? prevChapter.number : null,
|
||||||
next: nextChapter ? nextChapter.number : null,
|
next: nextChapter ? nextChapter.number : null,
|
||||||
@@ -214,6 +222,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
|||||||
lang: useTranslation ? lang : '',
|
lang: useTranslation ? lang : '',
|
||||||
translationStatus,
|
translationStatus,
|
||||||
isPro: locals.isPro,
|
isPro: locals.isPro,
|
||||||
|
isAdmin: locals.user?.role === 'admin',
|
||||||
chapterImageUrl,
|
chapterImageUrl,
|
||||||
audioReady,
|
audioReady,
|
||||||
availableVoice
|
availableVoice
|
||||||
|
|||||||
@@ -374,7 +374,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the normal path returned no content, fall back to live preview scrape
|
// If the normal path returned no content, fall back to live preview scrape
|
||||||
if (!data.isPreview && !data.html) {
|
// (but only when contentMissing is false — if the file is genuinely absent
|
||||||
|
// from storage the preview scrape won't help either and admins can re-queue)
|
||||||
|
if (!data.isPreview && !data.html && !data.contentMissing) {
|
||||||
fetchingContent = true;
|
fetchingContent = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -406,6 +408,62 @@
|
|||||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Admin chapter tools ────────────────────────────────────────────────────
|
||||||
|
let adminOpen = $state(false);
|
||||||
|
let scrapeStatus = $state<'idle' | 'busy' | 'queued' | 'error'>('idle');
|
||||||
|
let scrapeTaskId = $state('');
|
||||||
|
let scrapeProgress = $state('');
|
||||||
|
let pollTimer = 0;
|
||||||
|
|
||||||
|
async function scrapeThisChapter() {
|
||||||
|
if (scrapeStatus === 'busy' || !data.book.source_url) return;
|
||||||
|
scrapeStatus = 'busy';
|
||||||
|
scrapeProgress = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/scrape/range', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: data.book.source_url, from: data.chapter.number, to: data.chapter.number })
|
||||||
|
});
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
scrapeStatus = 'queued';
|
||||||
|
scrapeTaskId = d.task_id ?? '';
|
||||||
|
if (scrapeTaskId) startPollTask(scrapeTaskId);
|
||||||
|
} else if (res.status === 409) {
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
scrapeProgress = 'A scrape job is already running — try again shortly.';
|
||||||
|
} else {
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
scrapeProgress = d.message ?? `Error ${res.status}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
scrapeProgress = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPollTask(id: string) {
|
||||||
|
clearTimeout(pollTimer);
|
||||||
|
pollTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/scrape/task/${id}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const d = await res.json() as { status: string; chapters_scraped?: number; error_message?: string };
|
||||||
|
if (d.status === 'done') {
|
||||||
|
scrapeProgress = `Done — ${d.chapters_scraped ?? 0} chapter(s) scraped. Reload to see content.`;
|
||||||
|
scrapeStatus = 'queued';
|
||||||
|
} else if (d.status === 'failed') {
|
||||||
|
scrapeProgress = `Failed: ${d.error_message ?? 'unknown error'}`;
|
||||||
|
scrapeStatus = 'error';
|
||||||
|
} else {
|
||||||
|
scrapeProgress = `Status: ${d.status}${d.chapters_scraped ? ` (${d.chapters_scraped} done)` : ''}`;
|
||||||
|
startPollTask(id); // keep polling
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, 3000) as unknown as number;
|
||||||
|
}
|
||||||
|
|
||||||
// Strip scraper artifacts from chapter titles:
|
// Strip scraper artifacts from chapter titles:
|
||||||
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
||||||
// - Everything after the first newline (often includes a scraped date)
|
// - Everything after the first newline (often includes a scraped date)
|
||||||
@@ -717,8 +775,115 @@
|
|||||||
{m.reader_fetching_chapter()}
|
{m.reader_fetching_chapter()}
|
||||||
</div>
|
</div>
|
||||||
{:else if !html}
|
{:else if !html}
|
||||||
<div class="text-(--color-muted) text-center py-16">
|
<!-- ── Content unavailable ─────────────────────────────────────────── -->
|
||||||
<p>{fetchError || m.reader_audio_error()}</p>
|
<div class="mt-8 rounded-xl border border-(--color-border) bg-(--color-surface-2) overflow-hidden">
|
||||||
|
<!-- Banner — visible to all users -->
|
||||||
|
<div class="flex items-start gap-4 p-6">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-(--color-surface-3) flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-(--color-text) mb-1">Chapter content not available</p>
|
||||||
|
<p class="text-sm text-(--color-muted)">
|
||||||
|
{#if data.contentMissing}
|
||||||
|
This chapter exists in the index but its content hasn't been stored yet.
|
||||||
|
{#if !data.isAdmin}It should appear soon — try refreshing in a few minutes.{/if}
|
||||||
|
{:else}
|
||||||
|
{fetchError || 'Could not load this chapter. Please try again.'}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin toolbar — only visible to admins -->
|
||||||
|
{#if data.isAdmin}
|
||||||
|
<div class="border-t border-(--color-border)">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (adminOpen = !adminOpen)}
|
||||||
|
class="w-full flex items-center gap-2 px-5 py-3 text-xs font-semibold text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)/40 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
Admin Tools
|
||||||
|
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if adminOpen}
|
||||||
|
<div class="px-5 pb-5 flex flex-col gap-4 border-t border-(--color-border)">
|
||||||
|
|
||||||
|
<!-- Source URL info -->
|
||||||
|
{#if data.book.source_url}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs text-(--color-muted) mb-1">Source URL</p>
|
||||||
|
<a href={data.book.source_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
class="text-xs text-(--color-brand) hover:underline break-all">
|
||||||
|
{data.book.source_url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Scrape this chapter -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Scrape Chapter {data.chapter.number}</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={scrapeThisChapter}
|
||||||
|
disabled={scrapeStatus === 'busy' || !data.book.source_url}
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{#if scrapeStatus === 'busy'}
|
||||||
|
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Scraping…
|
||||||
|
{:else}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
Re-scrape ch.{data.chapter.number}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if !data.book.source_url}
|
||||||
|
<span class="text-xs text-(--color-muted)">No source URL on this book.</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if scrapeProgress}
|
||||||
|
<p class="text-xs {scrapeStatus === 'error' ? 'text-red-400' : 'text-green-400'}">{scrapeProgress}</p>
|
||||||
|
{:else if scrapeStatus === 'queued'}
|
||||||
|
<p class="text-xs text-(--color-muted)">Queued — polling for completion…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick links -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Quick links</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/admin/scrape" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||||
|
Scrape dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/books/{data.book.slug}" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||||
|
Book page
|
||||||
|
</a>
|
||||||
|
<a href="https://pb.libnovel.cc/_/" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||||
|
PocketBase ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
||||||
|
|||||||
Reference in New Issue
Block a user