Add clean-pdf tool page with web form and Zotero section
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function CleanPdfForm({ initialUsed, limit }: { initialUsed: number; limit: number }) {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [format, setFormat] = useState<"A4" | "Letter">("A4");
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [used, setUsed] = useState(initialUsed);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim() || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ url: url.trim(), format, theme });
|
||||||
|
const res = await fetch(`/api/pdf?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: `Ошибка ${res.status}` }));
|
||||||
|
setError(body.error ?? `Ошибка ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const usesCount = res.headers.get("X-Uses-Count");
|
||||||
|
if (usesCount) setUsed(Number(usesCount));
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||||
|
const filename = match ? decodeURIComponent(match[1]) : "document.pdf";
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
} catch {
|
||||||
|
setError("Сеть недоступна, попробуйте ещё раз");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Адрес статьи
|
||||||
|
<input className={field} style={fieldStyle} type="url" required placeholder="https://…"
|
||||||
|
value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm">Формат
|
||||||
|
<select className={field} style={fieldStyle} value={format} onChange={(e) => setFormat(e.target.value as "A4" | "Letter")}>
|
||||||
|
<option value="A4">A4</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Тема
|
||||||
|
<select className={field} style={fieldStyle} value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
|
||||||
|
<option value="light">Светлая</option>
|
||||||
|
<option value="dark">Тёмная</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button type="submit" disabled={busy} className="btn-aubade px-4 py-2 text-sm font-bold disabled:opacity-50">
|
||||||
|
{busy ? "Генерируем… (до минуты)" : "Скачать PDF"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>Использовано {used} из {limit} в этом месяце</span>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions";
|
||||||
|
|
||||||
|
export function ZoteroSection({ apiKey, script, baseUrl }: { apiKey: string; script: string; baseUrl: string }) {
|
||||||
|
const [key, setKey] = useState(apiKey);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4);
|
||||||
|
const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`;
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
const res = await regeneratePdfApiKey();
|
||||||
|
if (res.ok && res.key) { setKey(res.key); setRevealed(true); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: "var(--foreground)" }}>Zotero и API</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>ВАШ КЛЮЧ</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="p-2 text-sm" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>{shownKey}</code>
|
||||||
|
<button type="button" className="text-xs underline" onClick={() => setRevealed((v) => !v)}>
|
||||||
|
{revealed ? "скрыть" : "показать"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(key)}>скопировать</button>
|
||||||
|
<button type="button" className="text-xs underline" disabled={pending} onClick={regenerate}>
|
||||||
|
{pending ? "меняем…" : "перевыпустить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ключ персональный — не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
|
||||||
|
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>НАСТРОЙКА ZOTERO</div>
|
||||||
|
<ol className="list-decimal pl-5 flex flex-col gap-1">
|
||||||
|
<li>Установите плагин <a className="underline" href="https://github.com/windingwind/zotero-actions-tags/releases" target="_blank" rel="noreferrer">Actions & Tags</a>.</li>
|
||||||
|
<li>В настройках плагина нажмите «+» и создайте действие: Name — <b>Чистый PDF</b>, Operation — <b>Script</b>.</li>
|
||||||
|
<li>В поле Data вставьте скрипт ниже (ключ уже подставлен).</li>
|
||||||
|
<li>Menu Label — <b>_чистый PDF</b>, поставьте галочку <b>In item Menu</b>, нажмите Save.</li>
|
||||||
|
<li>Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeOutput code={script} tool="clean-pdf" label="СКРИПТ ДЛЯ ACTIONS & TAGS" />
|
||||||
|
<CodeOutput code={curlExample} tool="clean-pdf" label="ПРИМЕР ДЛЯ API (CURL)" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||||
|
import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys";
|
||||||
|
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||||
|
import { CleanPdfForm } from "./CleanPdfForm";
|
||||||
|
import { ZoteroSection } from "./ZoteroSection";
|
||||||
|
|
||||||
|
export const metadata = { title: "Чистый PDF — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default async function CleanPdfToolPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const userId = session?.user.id;
|
||||||
|
const paid = userId ? await hasPaidAccess(userId) : false;
|
||||||
|
|
||||||
|
if (!userId || !paid) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс — проверьте, что вы вошли в нужный аккаунт.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]);
|
||||||
|
const limit = getMonthlyLimit();
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru";
|
||||||
|
const zoteroScript = buildZoteroScript({ apiKey: key, baseUrl });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора — только текст, картинки и оглавление.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<CleanPdfForm initialUsed={used} limit={limit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<ZoteroSection apiKey={key} script={zoteroScript} baseUrl={baseUrl} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, Wrench, type LucideIcon } from "lucide-react";
|
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react";
|
||||||
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
||||||
@@ -10,6 +10,7 @@ const ICONS: Record<string, LucideIcon> = {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Table2,
|
Table2,
|
||||||
Database,
|
Database,
|
||||||
|
FileText,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases";
|
export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings" | "dataview" | "bases" | "clean-pdf";
|
||||||
|
|
||||||
export const TOOL_IDS: ToolId[] = [
|
export const TOOL_IDS: ToolId[] = [
|
||||||
"callout",
|
"callout",
|
||||||
@@ -7,6 +7,7 @@ export const TOOL_IDS: ToolId[] = [
|
|||||||
"style-settings",
|
"style-settings",
|
||||||
"dataview",
|
"dataview",
|
||||||
"bases",
|
"bases",
|
||||||
|
"clean-pdf",
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ToolMeta {
|
export interface ToolMeta {
|
||||||
@@ -24,4 +25,5 @@ export const TOOLS: ToolMeta[] = [
|
|||||||
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
|
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
|
||||||
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
|
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
|
||||||
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
|
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
|
||||||
|
{ id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" },
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user