diff --git a/src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx b/src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx new file mode 100644 index 0000000..0a5fa35 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/CleanPdfForm.tsx @@ -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(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 ( +
+ +
+ + +
+
+ + Использовано {used} из {limit} в этом месяце +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx new file mode 100644 index 0000000..20b4b14 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/ZoteroSection.tsx @@ -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 ( +
+

Zotero и API

+ +
+
ВАШ КЛЮЧ
+
+ {shownKey} + + + +
+

+ Ключ персональный — не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится. +

+
+ +
+
НАСТРОЙКА ZOTERO
+
    +
  1. Установите плагин Actions & Tags.
  2. +
  3. В настройках плагина нажмите «+» и создайте действие: Name — Чистый PDF, Operation — Script.
  4. +
  5. В поле Data вставьте скрипт ниже (ключ уже подставлен).
  6. +
  7. Menu Label — _чистый PDF, поставьте галочку In item Menu, нажмите Save.
  8. +
  9. Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.
  10. +
+
+ + + +
+ ); +} diff --git a/src/app/(student)/tools/clean-pdf/page.tsx b/src/app/(student)/tools/clean-pdf/page.tsx new file mode 100644 index 0000000..2d767e9 --- /dev/null +++ b/src/app/(student)/tools/clean-pdf/page.tsx @@ -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 ( +
+

Чистый PDF

+
+

+ Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс — проверьте, что вы вошли в нужный аккаунт. +

+
+
+ ); + } + + 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 ( +
+

Чистый PDF

+

+ Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора — только текст, картинки и оглавление. +

+ +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/components/tools/ToolCard.tsx b/src/components/tools/ToolCard.tsx index 247c390..8b9ef34 100644 --- a/src/components/tools/ToolCard.tsx +++ b/src/components/tools/ToolCard.tsx @@ -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 = { SlidersHorizontal, Table2, Database, + FileText, }; export function ToolCard({ tool }: { tool: ToolMeta }) { diff --git a/src/lib/tools/_shared/types.ts b/src/lib/tools/_shared/types.ts index 07be91b..09cdcc0 100644 --- a/src/lib/tools/_shared/types.ts +++ b/src/lib/tools/_shared/types.ts @@ -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" }, ];