Compare commits

..

2 Commits

6 changed files with 202 additions and 2 deletions
@@ -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 &amp; 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>
);
}
+2 -1
View File
@@ -1,5 +1,5 @@
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 * as Icons`) — иначе весь набор lucide попадает в бандл.
@@ -10,6 +10,7 @@ const ICONS: Record<string, LucideIcon> = {
SlidersHorizontal,
Table2,
Database,
FileText,
};
export function ToolCard({ tool }: { tool: ToolMeta }) {
+13
View File
@@ -0,0 +1,13 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { hasPaidAccess } from "@/lib/clean-pdf/access";
import { regenerateKey } from "@/lib/clean-pdf/keys";
export async function regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { ok: false };
if (!(await hasPaidAccess(session.user.id))) return { ok: false };
const key = await regenerateKey(session.user.id);
return { ok: true, key };
}
+3 -1
View File
@@ -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[] = [
"callout",
@@ -7,6 +7,7 @@ export const TOOL_IDS: ToolId[] = [
"style-settings",
"dataview",
"bases",
"clean-pdf",
];
export interface ToolMeta {
@@ -24,4 +25,5 @@ export const TOOLS: ToolMeta[] = [
{ id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" },
{ id: "dataview", title: "Генератор Dataview", description: "Запросы DQL: таблицы, списки и задачи по заметкам.", icon: "Table2" },
{ id: "bases", title: "Генератор Bases", description: "Базы Obsidian (.base): представления с фильтрами.", icon: "Database" },
{ id: "clean-pdf", title: "Чистый PDF", description: "Любая веб-страница → аккуратный PDF без рекламы и мусора. Интеграция с Zotero.", icon: "FileText" },
];