From fc5c27f008fa4e77bb6278bb8b0bbcf241a99df0 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 13:10:40 +0000 Subject: [PATCH 01/19] docs: add Yoinkit Pro feature gating, gallery, legal & payment spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-22-yoinkit-pro-design.md | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md diff --git a/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md b/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md new file mode 100644 index 0000000..00b0d85 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md @@ -0,0 +1,372 @@ +# Yoinkit Pro — Feature Gating, Gallery, Legal & Payment Design + +**Date:** 2026-03-22 +**Status:** Approved +**Author:** Claude + naw + +--- + +## 1. Overview + +Yoinkit is a macOS desktop app (Tauri v2 + React/TypeScript) providing a GUI for wget/yt-dlp/ffmpeg. This spec defines the free/pro feature split, gallery feature, payment system, legal compliance framework, and upgrade UX. + +**Pricing:** GBP 19 one-time purchase. No subscription. + +**Positioning:** Personal web toolkit — not a "download manager" or "web scraper." + +--- + +## 2. Free vs Pro Feature Split + +### 2.1 Free Tier + +All core features work. No crippling. Free users are genuinely happy. + +| Feature | Free Limit | +|---|---| +| Yoinks (downloads) | Single URL, single thread | +| Video | Up to 720p, MP3 format only | +| Audio | MP3 128kbps only | +| Gallery | 50 items, chronological flat list, grid/list view | +| Clipper | Full functionality | +| Archive | Full functionality | +| Images | Full functionality | +| Search | Full history, basic text search | +| AI | Full (auto-tag, summarize, Ask My Yoinks, digest) | +| Yoink Receipt | Full (brand spreads virally) | + +### 2.2 Pro Tier (GBP 19 one-time) + +Power features and organisation. The upgrade trigger is frequency-based — the more you use the app, the more you want Pro. + +| Feature | Pro Unlock | +|---|---| +| Video | 4K, 1080p, all formats (MP4, MKV, WebM) | +| Audio | 320kbps, FLAC, WAV, AAC, Opus, quality selector | +| Gallery | Unlimited items, collections, projects, tags, flags, smart folders, sort by kind/size/source, filter by kind/date/tag/collection/flag, hover preview, bulk actions | +| Batch operations | Batch download, batch clip, batch export | +| Multi-threaded downloads | Chunked parallel downloading via HTTP Range | +| Scheduling | Download scheduler, site change monitor | +| MCP server | Claude Desktop integration | +| Wget command builder | Visual flag builder + preset manager | +| Advanced search | Regex, filters, saved searches | + +### 2.3 Design Principles + +- Free users never feel punished — every page works, every core feature works +- Video/audio quality is the daily upgrade reminder (every download shows available higher quality) +- Gallery cap is the weekly reminder (hits 50 after a few weeks of regular use) +- Batch/scheduling/MCP are power-user magnets that justify the price alone +- Clipper, Archive, Images, AI, and Yoink Receipt stay fully free — these are the hooks that get people using and sharing the app + +--- + +## 3. Gallery + +### 3.1 Purpose + +Central hub for everything you have yoinked. Makes the app feel like a personal library, not just a downloader. Retention feature that keeps people coming back daily. + +### 3.2 Content + +Everything appears in the gallery automatically when yoinked: +- Downloads (file type icon, video thumbnails where available) +- Clips (article preview with title + snippet) +- Archives (page snapshot with favicon + title) +- Scraped images (thumbnail grid) + +### 3.3 Free Gallery + +- 50 items max +- Flat chronological list (newest first) +- Grid or list view toggle +- Click to open file / view clip +- Basic info per item: title, type icon, date added, source URL +- Counter in header when > 30 items: "42/50" +- When full: non-blocking banner at top — "Gallery full. 50/50 items. Upgrade for unlimited + collections" + +### 3.4 Pro Gallery + +Everything in free, plus: + +- **Unlimited items** — no cap +- **Collections** — user-created folders (e.g. "Kitchen Reno Research", "Music Production") +- **Smart folders** — auto-populated by rules (e.g. "all videos", "clips from this week", "items tagged work") +- **Tags** — user-applied + AI auto-tags +- **Flags** — star, pin, archive, custom colour flags +- **Sort by** — date, kind, size, source domain, title +- **Filter by** — kind (video/audio/clip/image/archive), date range, tag, collection, flag +- **Hover preview** — thumbnail expand, clip snippet, audio waveform +- **Bulk actions** — multi-select, move to collection, tag, flag, delete, export + +### 3.5 Navigation + +Gallery sits in the sidebar nav as the second item: + +``` +Yoinks > Gallery > Video > Audio > Images > Clipper > Archive > Search > Ask > Pro > Settings +``` + +- "Yoinks" replaces the current "Downloads" label — same page, renamed +- "Gallery" is new + +--- + +## 4. Pro Page & Upgrade Experience + +### 4.1 Pro Page (Free State) — the shop window + +Not a wall of text. A visual sell. + +| Section | Content | +|---|---| +| Hero | "Unlock the full toolkit" with GBP 19 one-time, yours forever | +| Quality | Side-by-side: "720p MP3 to 4K FLAC" with format badges | +| Gallery | "50 items to Unlimited, organised your way" with mock collection/tag UI | +| Power | Icon grid: Batch, Multi-thread, Scheduling, MCP, Wget Builder, Advanced Search | +| Social proof | (Future) review quotes, download count | +| CTA | "Upgrade to Pro" button with "One-time purchase. No subscription. Ever." | + +### 4.2 Pro Page (Unlocked State) + +Transforms into a Pro dashboard: +- Quick links to Pro-only features (scheduler, wget builder, etc.) +- Gallery stats (total items, collections, storage used) +- "Pro since [date]" badge +- MCP server status / setup guide + +### 4.3 Nudge System + +Rule: inform, never interrupt. No modals, no popups, no blocking flows. + +| Location | Nudge | When | +|---|---|---| +| Video quality selector | 1080p/4K visible but greyed, small "Pro" pill | Every use | +| Audio format selector | FLAC/WAV/AAC greyed, "Pro" pill | Every use | +| Gallery counter | "42/50" muted text in header | When > 30 items | +| Gallery full | Banner: "Gallery full. Upgrade for unlimited + collections" | At 50 items | +| Batch URL input | "Paste multiple URLs" with lock icon + "Pro" | When user tries | +| Scheduler | Visible but locked with "Pro" overlay | When user navigates | +| MCP settings | "Connect to Claude Desktop. Pro" | In settings | +| Wget builder | Visible but locked with "Pro" overlay | When user navigates | + +### 4.4 Locked Feature Appearance + +The feature is visible — you can see what it does, you just cannot use it yet. + +- Greyed-out controls with a small "Pro" badge +- One-line explanation: "Upgrade to unlock 4K downloads. GBP 19 one-time" +- Tapping a locked control navigates to the Pro page, not a modal + +### 4.5 Unlock Celebration + +When Pro activates: +- Confetti animation (subtle, 2 seconds) +- "Welcome to Pro" toast notification +- Gallery limit removed immediately +- Quality selectors unlock immediately +- Nav "Pro" item gets a small checkmark or crown + +--- + +## 5. Payment & Licensing + +### 5.1 Payment Provider: LemonSqueezy + +| Factor | Detail | +|---|---| +| Merchant of record | Handles VAT collection, tax remittance, invoicing, refunds across all jurisdictions | +| License key API | Built-in, no custom backend needed | +| Fee | Approximately 5% + 50p per sale (approximately GBP 1.45 on GBP 19) | +| Take-home | Approximately GBP 17.55 per sale | +| GDPR compliant | Yes | + +### 5.2 License Key Flow + +1. User clicks "Upgrade to Pro" anywhere in app +2. Opens yoinkit.app/pro in default browser (LemonSqueezy checkout page) +3. User pays GBP 19 +4. LemonSqueezy generates license key +5. User copies key back into app (Settings > Pro > Enter License Key) +6. App validates key against LemonSqueezy API (single HTTPS POST) +7. `pro_unlocked = true` stored in local SQLite settings +8. All Pro features unlock immediately + +### 5.3 Validation Rules + +| Rule | Detail | +|---|---| +| Online validation | Once on activation only. Works offline forever after | +| Device limit | 3 activations per key (desktop, laptop, second Mac) | +| No subscription check | One-time validation, no recurring phone-home | +| Grace on failure | If validation API is down, allow 7-day grace period | +| Key storage | Stored locally in SQLite settings, never transmitted again | + +### 5.4 Pricing Strategy + +| Price | Context | +|---|---| +| GBP 19 | Standard price | +| GBP 14 | Launch discount (first 30 days or first 500 licenses) | +| GBP 9 | "Friend of Yoinkit" referral code (buyer gets GBP 10 off) | +| Free Pro | Open source contributors who submit merged PRs | + +--- + +## 6. Legal & Compliance Framework + +### 6.1 First Launch Agreement + +On first open, before any functionality is available: + +- "Welcome to Yoinkit" screen +- Summary of key terms in plain English: + - "Yoinkit is a personal web toolkit for saving content to your own device" + - "You are responsible for ensuring you have the right to save content you download" + - "Respect creators — credit original sources, do not redistribute copyrighted material" +- Checkbox: "I agree to the Terms of Use and Privacy Policy" +- Links to full Terms of Use and Privacy Policy +- Cannot proceed without agreeing +- Agreement timestamp stored in local DB + +### 6.2 Terms of Use + +Modelled on 4K Video Downloader (the most legally comprehensive tool in the category). + +| Provision | Detail | +|---|---| +| User responsibility | "All legal disputes arising from content you download or save are your sole responsibility" | +| Permitted use | Personal, non-commercial use. Research, education, archiving, fair dealing | +| Prohibited use | Do not use Yoinkit to infringe copyright, redistribute protected content, or violate any platform's terms of service | +| Content-neutral | Never name YouTube, Spotify, or any specific platform in terms or marketing | +| No warranty | Software provided "AS IS" without warranties of any kind | +| Liability cap | Maximum liability capped at purchase price (GBP 19 for Pro, GBP 0 for free) | +| Governing law | England and Wales | + +### 6.3 DMCA Safe Harbor + +| Requirement | Implementation | +|---|---| +| Designated agent | Register at copyright.gov (USD 6 filing fee) | +| Contact info | copyright@yoinkit.app published on website and in app | +| Repeat infringer policy | Documented policy (required for safe harbor eligibility) | +| Takedown response | Documented process for responding to DMCA notices | + +### 6.4 Privacy Policy (UK GDPR Compliant) + +| Principle | Implementation | +|---|---| +| Local-first | All data stored on user's Mac. No server, no telemetry, no analytics | +| No data collection | App collects zero personal data. No accounts, no tracking | +| AI provider disclosure | "If you enable AI features, your content is sent to your chosen provider (Ollama local / Claude / OpenAI). Review their privacy policies." | +| License key | Only data transmitted: license key validation (one-time HTTPS call to LemonSqueezy) | +| Data minimisation | Nothing leaves the device except user-initiated AI queries and the one-time license check | + +### 6.5 In-App Legal Touchpoints + +Subtle, helpful guidance woven into the UI. Not legal walls. + +| Location | Message | +|---|---| +| Video page | Info icon: "Ensure you have permission to download this content. Learn more" | +| Clipper page | "Clips are saved locally for personal reference. Credit original creators when sharing." | +| Archive page | "Archives are for personal offline access. Fair dealing applies to research and private study." | +| Yoink Receipt | Auto-includes source URL — built-in creator attribution | +| Gallery export | "Exported content may be protected by copyright. Do not redistribute without permission." | +| Settings > Legal | Links to full ToS, Privacy Policy, DMCA info, fair use guide | + +### 6.6 Content-Neutral Marketing + +For website, App Store listing, README, and all public materials. + +| Do | Do Not | +|---|---| +| "Download files from the web" | "Download from YouTube" | +| "Extract audio from media" | "Rip music from Spotify" | +| "Save web pages for offline reading" | "Archive paywalled articles" | +| "Personal web toolkit" | "Download manager" | +| "Clip articles for research" | "Bypass paywalls" | + +--- + +## 7. Competitor Analysis Summary + +Yoinkit is the only tool that combines download manager + media extractor + web clipper + archiver + AI knowledge base in one GUI app. + +| Competitor Category | Examples | What Yoinkit adds | +|---|---|---| +| CLI download tools | wget, cURL, Aria2 | GUI, video/audio, clipping, AI, search | +| GUI download managers | uGet, FlareGet | Also clips, archives, AI, search | +| Website archivers | ArchiveBox, WebCopy | Also downloads, clips to Markdown, Obsidian export | +| Video downloaders | Allavsoft | Also downloads files, clips, archives, AI | +| Enterprise scraping | PageFreezer ($99/mo), WebScrapingAPI ($49/mo) | Local-first, GBP 19 one-time, privacy-respecting | + +Legal positioning relative to competitors: +- More comprehensive than yt-dlp, Aria2 (which have zero disclaimers) +- On par with 4K Video Downloader (gold standard for commercial tools) +- Better user education than FlareGet or Allavsoft +- Local-first architecture provides privacy-by-design protection (similar to Cobalt.tools) + +--- + +## 8. Technical Notes + +### 8.1 Pro Gating Implementation + +- `pro_unlocked: bool` already exists in AppSettings / SQLite settings +- Add `license_key: Option` and `pro_since: Option` to settings +- Pro gate check: utility function `is_pro(&settings) -> bool` used in Tauri commands and frontend +- Frontend: `usePro()` hook reads pro status from settings, gates UI accordingly + +### 8.2 Gallery Data Model + +Gallery items are a unified view across existing tables (downloads, clips, archives). Implementation options: + +- **Option A (recommended):** Gallery is a virtual view — queries downloads + clips + archives, unions results, applies gallery-specific metadata (collections, tags, flags) from a `gallery_items` join table +- **Option B:** Separate `gallery` table with denormalised data copied on yoink + +### 8.3 Database Additions + +```sql +-- Gallery organisation (Pro) +CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS gallery_meta ( + item_id TEXT PRIMARY KEY, + item_type TEXT NOT NULL, -- 'download', 'clip', 'archive' + collection_id TEXT, + tags TEXT DEFAULT '', + flag TEXT DEFAULT '', -- 'star', 'pin', 'archive', or color hex + added_at TEXT NOT NULL, + FOREIGN KEY (collection_id) REFERENCES collections(id) +); + +CREATE TABLE IF NOT EXISTS smart_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + rules_json TEXT NOT NULL, -- JSON filter rules + created_at TEXT NOT NULL +); + +-- Legal +CREATE TABLE IF NOT EXISTS legal_consent ( + id INTEGER PRIMARY KEY, + tos_version TEXT NOT NULL, + accepted_at TEXT NOT NULL +); +``` + +### 8.4 New Settings Fields + +``` +license_key: Option +pro_since: Option +tos_accepted: bool +tos_accepted_at: Option +gallery_view: String -- 'grid' or 'list' +``` From 75f2740eb1dfcd0fb6f535c5ffac5e3d323fa486 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 13:41:41 +0000 Subject: [PATCH 02/19] =?UTF-8?q?docs:=20address=20spec=20review=20?= =?UTF-8?q?=E2=80=94=20fix=20critical=20and=20major=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix gallery data model: union downloads + clips (no archives table) - Fix gallery_meta primary key: composite (item_id, item_type) - Add position columns to collections and gallery_meta - Clarify navigation rename (page ID + label changes) - Define migrate_v4 explicitly - Resolve ToS storage: legal_consent table is source of truth - Define smart folder rules_json schema - Clarify multi-threaded download implementation approach - Add gating decisions for existing features (exports, digest, etc.) - Clarify scheduling status (built in v2, gated to Pro) - Define usePro() hook interface - Clarify grace period (initial activation only) - Define referral codes as LemonSqueezy discount codes Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-22-yoinkit-pro-design.md | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md b/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md index 00b0d85..1112982 100644 --- a/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md +++ b/docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md @@ -199,7 +199,7 @@ When Pro activates: | Online validation | Once on activation only. Works offline forever after | | Device limit | 3 activations per key (desktop, laptop, second Mac) | | No subscription check | One-time validation, no recurring phone-home | -| Grace on failure | If validation API is down, allow 7-day grace period | +| Grace on failure | Initial activation only: if LemonSqueezy API is unreachable, store the key locally and retry on next app launch for up to 7 days. After successful validation, no further network calls are ever made. This is NOT a recurring check — once validated, Pro is permanent and fully offline. | | Key storage | Stored locally in SQLite settings, never transmitted again | ### 5.4 Pricing Strategy @@ -208,8 +208,8 @@ When Pro activates: |---|---| | GBP 19 | Standard price | | GBP 14 | Launch discount (first 30 days or first 500 licenses) | -| GBP 9 | "Friend of Yoinkit" referral code (buyer gets GBP 10 off) | -| Free Pro | Open source contributors who submit merged PRs | +| GBP 9 | "Friend of Yoinkit" referral code (buyer gets GBP 10 off). Implemented as LemonSqueezy discount codes — no custom referral tracking system needed. Codes distributed manually via social media, community, or direct sharing. | +| Free Pro | Open source contributors who submit merged PRs. Manual process — generate a free license key via LemonSqueezy dashboard. | --- @@ -316,16 +316,37 @@ Legal positioning relative to competitors: - `pro_unlocked: bool` already exists in AppSettings / SQLite settings - Add `license_key: Option` and `pro_since: Option` to settings - Pro gate check: utility function `is_pro(&settings) -> bool` used in Tauri commands and frontend -- Frontend: `usePro()` hook reads pro status from settings, gates UI accordingly +- Frontend: `usePro()` hook — convenience wrapper around `useSettings()`: + - Returns `{ isPro: bool, proSince: Option, loading: bool }` + - Reads `settings.pro_unlocked` as the source of truth + - No grace period logic — see Section 5.3 clarification ### 8.2 Gallery Data Model -Gallery items are a unified view across existing tables (downloads, clips, archives). Implementation options: +Gallery items are a unified view across existing tables. Note: there is no separate `archives` table — archives are stored as clips with `source_type = 'archive'` in the `clips` table. Similarly, scraped images that are downloaded go through the download manager and are stored in the `downloads` table. -- **Option A (recommended):** Gallery is a virtual view — queries downloads + clips + archives, unions results, applies gallery-specific metadata (collections, tags, flags) from a `gallery_items` join table -- **Option B:** Separate `gallery` table with denormalised data copied on yoink +The gallery therefore unions two tables: +- `downloads` — all file downloads (including scraped images) +- `clips` — all clips (including archives where `source_type = 'archive'`) -### 8.3 Database Additions +**Implementation (recommended):** Gallery is a virtual view — queries `downloads` UNION `clips`, applies gallery-specific metadata (collections, tags, flags) via a `gallery_meta` join table. The `item_type` in `gallery_meta` maps to the source: `'download'` or `'clip'`. + +The gallery page renders items differently based on type: +- `download` items: file type icon, video thumbnail where available +- `clip` with `source_type = 'article'|'page'`: article preview with title + snippet +- `clip` with `source_type = 'archive'`: page snapshot with favicon + title + +### 8.3 Navigation Rename + +The internal page ID changes from `"simple"` to `"yoinks"` in the `Page` type union and `NAV_ITEMS` array. The label changes from "Downloads" to "Yoinks". A new `"gallery"` page ID is added. No deep linking or persisted page preferences exist currently, so this is a clean rename with no migration concerns. + +```typescript +type Page = "yoinks" | "gallery" | "video" | "audio" | "images" | "clipper" | "archive" | "search" | "ai" | "pro" | "settings" +``` + +### 8.4 Database Migration (v4) + +All new tables and settings are added in `migrate_v4()`, called from `Database::new()` after `migrate_v3()`. ```sql -- Gallery organisation (Pro) @@ -333,27 +354,30 @@ CREATE TABLE IF NOT EXISTS collections ( id TEXT PRIMARY KEY, name TEXT NOT NULL, color TEXT, + position INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS gallery_meta ( - item_id TEXT PRIMARY KEY, - item_type TEXT NOT NULL, -- 'download', 'clip', 'archive' + item_id TEXT NOT NULL, + item_type TEXT NOT NULL, -- 'download' or 'clip' collection_id TEXT, tags TEXT DEFAULT '', flag TEXT DEFAULT '', -- 'star', 'pin', 'archive', or color hex + position INTEGER DEFAULT 0, added_at TEXT NOT NULL, + PRIMARY KEY (item_id, item_type), FOREIGN KEY (collection_id) REFERENCES collections(id) ); CREATE TABLE IF NOT EXISTS smart_folders ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - rules_json TEXT NOT NULL, -- JSON filter rules + rules_json TEXT NOT NULL, created_at TEXT NOT NULL ); --- Legal +-- Legal consent (source of truth for ToS acceptance) CREATE TABLE IF NOT EXISTS legal_consent ( id INTEGER PRIMARY KEY, tos_version TEXT NOT NULL, @@ -361,12 +385,58 @@ CREATE TABLE IF NOT EXISTS legal_consent ( ); ``` -### 8.4 New Settings Fields +Smart folder `rules_json` schema: + +```json +{ + "match": "all", + "rules": [ + { "field": "item_type", "op": "eq", "value": "clip" }, + { "field": "created_at", "op": "within", "value": "7d" }, + { "field": "tags", "op": "contains", "value": "work" }, + { "field": "flag", "op": "eq", "value": "star" } + ] +} +``` + +Fields: `item_type`, `tags`, `flag`, `collection_id`, `created_at`, `source_type` (for clips). +Operators: `eq`, `neq`, `contains`, `within` (time period: `7d`, `30d`, `90d`). +Match: `all` (AND) or `any` (OR). + +### 8.5 New Settings Fields ``` license_key: Option pro_since: Option -tos_accepted: bool -tos_accepted_at: Option gallery_view: String -- 'grid' or 'list' ``` + +Note: ToS acceptance is tracked in the `legal_consent` table (not in settings) because it needs to store the version agreed to and support re-consent if the ToS version changes. The app checks `legal_consent` on startup — if no row exists or the latest `tos_version` is older than the current version, the welcome/consent screen is shown. + +### 8.6 Multi-Threaded Downloads + +Multi-threaded downloads are already partially implemented in `wget.rs`: +- `check_range_support(url)` — HEAD request checking `Accept-Ranges` header and `Content-Length` +- `download_chunk(url, start, end, output_path)` — wget with `--header "Range: bytes=start-end"` +- `concatenate_chunks(chunk_paths, output_path)` — merges chunks and cleans up temp files + +For Pro gating: the `download_file` command checks `is_pro(&settings)`. If Pro, it calls `check_range_support()` first — if the server supports ranges, it splits into N chunks (default 4, configurable) and downloads in parallel via `tokio::spawn`. If not Pro or server does not support ranges, it falls back to single-thread wget. + +This is a Rust-orchestrated approach using wget as the download backend for each chunk. No dependency on aria2 or other external tools. + +### 8.7 Existing Features — Gating Decisions + +Features already implemented that are not explicitly in the free/pro tables: + +| Feature | Tier | Rationale | +|---|---|---| +| Obsidian vault export | Free | Part of the clipper hook — keeps users engaged | +| NotebookLM export (single) | Free | Single export is a taste of the feature | +| NotebookLM export (batch) | Pro | Batch operations are Pro | +| Transcript structuring | Free | AI features stay free to hook users | +| Link status checking | Free | Utility feature, low upgrade value | +| Weekly digest generation | Free | AI feature, keeps users coming back | + +### 8.8 Scheduling — Status Note + +Download scheduling and site change monitoring are already implemented in the codebase (`scheduler.rs`, `monitor.rs`, `schedules` and `monitors` tables). The original v1 design spec deferred these to v2 — they were built during the v2 features phase. This Pro spec gates them behind Pro, which is their shipping state. The original spec should be considered superseded for scheduling-related sections. From 49d139217585a463b5a571a2b7fcfc515b16af1d Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 14:54:27 +0000 Subject: [PATCH 03/19] docs: add Pro implementation plan with review fixes 14 tasks across 4 phases: backend foundation, frontend foundation, Pro gating & upgrade UX, final integration. Review fixes applied: - Add schema_version insert + version guard to migrate_v4 - Add backfill query for existing downloads/clips in gallery - Add hostname crate to Cargo.toml step - Fix video/audio gating code to match actual parameter types - Wire gallery_meta cleanup into delete commands - Add useEffect import note for App.tsx - Update DEFAULT_SETTINGS in useSettings.ts - Fix CSS variable opacity syntax for Tailwind - Use named exports consistently - Fix parallelism batch table (Task 5 depends on Task 3) - Explicitly defer Pro Gallery advanced features to follow-up plan Co-Authored-By: Claude Opus 4.6 --- .../2026-03-22-yoinkit-pro-implementation.md | 1739 +++++++++++++++++ 1 file changed, 1739 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-yoinkit-pro-implementation.md diff --git a/docs/superpowers/plans/2026-03-22-yoinkit-pro-implementation.md b/docs/superpowers/plans/2026-03-22-yoinkit-pro-implementation.md new file mode 100644 index 0000000..435ee55 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-yoinkit-pro-implementation.md @@ -0,0 +1,1739 @@ +# Yoinkit Pro Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Pro feature gating, Gallery, legal consent, license key validation, navigation rename, and upgrade UX as defined in the Pro design spec. + +**Architecture:** Pro gating uses the existing `pro_unlocked` boolean in settings, extended with license key validation via LemonSqueezy API. Gallery is a virtual view unioning `downloads` and `clips` tables with a `gallery_meta` join table for Pro organisation features. Legal consent is tracked in a `legal_consent` table checked on app startup. + +**Tech Stack:** Rust/Tauri backend, React/TypeScript frontend, SQLite, LemonSqueezy API (license validation) + +**Spec:** `docs/superpowers/specs/2026-03-22-yoinkit-pro-design.md` + +--- + +## File Structure + +### Rust (apps/desktop/src-tauri/src/) + +| File | Action | Responsibility | +|---|---|---| +| `db.rs` | Modify | Add `migrate_v4()` — collections, gallery_meta, smart_folders, legal_consent tables. Add gallery CRUD, legal consent CRUD. Add new settings defaults. | +| `settings.rs` | Modify | Add `license_key`, `pro_since`, `gallery_view` fields to `AppSettings` | +| `commands.rs` | Modify | Add gallery commands, license validation command, legal consent commands. Add Pro gating to video/audio quality, batch, scheduling, monitor, MCP commands. | +| `license.rs` | Create | LemonSqueezy license key validation logic | +| `lib.rs` | Modify | Register new commands, add `license` module | + +### React (apps/desktop/src/) + +| File | Action | Responsibility | +|---|---|---| +| `App.tsx` | Modify | Rename nav, add Gallery, add legal consent gate | +| `lib/tauri.ts` | Modify | Add new TypeScript interfaces and API methods | +| `hooks/usePro.ts` | Create | Pro status convenience hook | +| `hooks/useGallery.ts` | Create | Gallery data fetching, pagination, filtering | +| `pages/SimplePage.tsx` | Rename | Becomes conceptually "Yoinks" (label change only in App.tsx) | +| `pages/GalleryPage.tsx` | Create | Gallery grid/list view with free/pro split | +| `pages/VideoPage.tsx` | Modify | Add Pro gating to quality selector | +| `pages/AudioPage.tsx` | Modify | Add Pro gating to format/quality selector | +| `pages/ProPage.tsx` | Rewrite | New shop window (free) / dashboard (unlocked) | +| `pages/SettingsPage.tsx` | Modify | Add license key input, legal links | +| `components/ProBadge.tsx` | Create | Reusable "Pro" pill badge | +| `components/ProOverlay.tsx` | Create | Locked feature overlay | +| `components/GalleryItem.tsx` | Create | Single gallery item card (grid/list variants) | +| `components/GalleryToolbar.tsx` | Create | Sort, filter, view toggle, collection picker (Pro) | +| `components/LegalConsent.tsx` | Create | First-launch ToS agreement screen | +| `components/ConfettiCelebration.tsx` | Create | Pro unlock celebration animation | + +--- + +## Phase 1: Backend Foundation (Tasks 1-4) + +### Task 1: Database Migration v4 + +**Files:** +- Modify: `apps/desktop/src-tauri/src/db.rs` + +- [ ] **Step 1: Add migrate_v4 function** + +Add after the existing `migrate_v3` function (around line 230): + +```rust +fn migrate_v4(conn: &Connection) -> Result<()> { + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + position INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS gallery_meta ( + item_id TEXT NOT NULL, + item_type TEXT NOT NULL, + collection_id TEXT, + tags TEXT DEFAULT '', + flag TEXT DEFAULT '', + position INTEGER DEFAULT 0, + added_at TEXT NOT NULL, + PRIMARY KEY (item_id, item_type), + FOREIGN KEY (collection_id) REFERENCES collections(id) + ); + + CREATE TABLE IF NOT EXISTS smart_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + rules_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS legal_consent ( + id INTEGER PRIMARY KEY, + tos_version TEXT NOT NULL, + accepted_at TEXT NOT NULL + ); + + + -- Backfill gallery_meta for existing downloads + INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) + SELECT id, 'download', created_at FROM downloads; + + -- Backfill gallery_meta for existing clips + INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) + SELECT id, 'clip', created_at FROM clips; + ")?; + conn.execute( + "INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (4, datetime('now'))", + [], + )?; + Ok(()) +} +``` + +- [ ] **Step 2: Call migrate_v4 from Database::new() with version guard** + +In the `new()` method, follow the existing pattern (see `migrate_v2`/`migrate_v3` guards around line 106-114). Add: + +```rust +if current_version < 4 { + migrate_v4(&conn)?; +} +``` + +Do the same in `new_with_path()`. + +- [ ] **Step 2b: Add `hostname` dependency to Cargo.toml** + +In `apps/desktop/src-tauri/Cargo.toml`, add to `[dependencies]`: + +```toml +hostname = "0.4" +``` + +- [ ] **Step 3: Add new settings defaults** + +In `init_default_settings()`, add these to the defaults vec: + +```rust +("license_key", ""), +("pro_since", ""), +("gallery_view", "grid"), +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src-tauri/src/db.rs +git commit -m "feat(db): add migrate_v4 — gallery, collections, legal consent tables" +``` + +--- + +### Task 2: Gallery CRUD in Database + +**Files:** +- Modify: `apps/desktop/src-tauri/src/db.rs` + +- [ ] **Step 1: Add GalleryItem and Collection structs** + +Add after the existing struct definitions: + +```rust +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GalleryItem { + pub item_id: String, + pub item_type: String, // "download" or "clip" + pub title: String, + pub url: String, + pub source_type: String, // "download", "article", "archive", etc. + pub collection_id: Option, + pub tags: String, + pub flag: String, + pub added_at: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Collection { + pub id: String, + pub name: String, + pub color: Option, + pub position: i32, + pub created_at: String, +} +``` + +- [ ] **Step 2: Add gallery_meta auto-insert to existing insert methods** + +In `insert_download()`, after the existing INSERT, add: + +```rust +conn.execute( + "INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) VALUES (?1, 'download', ?2)", + params![download.id, download.created_at], +)?; +``` + +In `insert_clip()`, after the existing INSERT, add: + +```rust +conn.execute( + "INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) VALUES (?1, 'clip', ?2)", + params![clip.id, clip.created_at], +)?; +``` + +- [ ] **Step 3: Add list_gallery_items method** + +```rust +pub fn list_gallery_items(&self, limit: usize, offset: usize) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT g.item_id, g.item_type, + COALESCE(d.url, c.url) as url, + CASE g.item_type + WHEN 'download' THEN COALESCE( + REPLACE(REPLACE(d.save_path, RTRIM(d.save_path, REPLACE(d.save_path, '/', '')), ''), '/', ''), + d.url) + WHEN 'clip' THEN COALESCE(c.title, c.url) + END as title, + CASE g.item_type + WHEN 'download' THEN 'download' + WHEN 'clip' THEN COALESCE(c.source_type, 'clip') + END as source_type, + g.collection_id, g.tags, g.flag, g.added_at, + COALESCE(d.created_at, c.created_at) as created_at + FROM gallery_meta g + LEFT JOIN downloads d ON g.item_type = 'download' AND g.item_id = d.id + LEFT JOIN clips c ON g.item_type = 'clip' AND g.item_id = c.id + ORDER BY g.added_at DESC + LIMIT ?1 OFFSET ?2" + )?; + let rows = stmt.query_map(params![limit as i64, offset as i64], |row| { + Ok(GalleryItem { + item_id: row.get(0)?, + item_type: row.get(1)?, + url: row.get(2)?, + title: row.get(3)?, + source_type: row.get(4)?, + collection_id: row.get(5)?, + tags: row.get(6)?, + flag: row.get(7)?, + added_at: row.get(8)?, + created_at: row.get(9)?, + }) + })?; + rows.collect() +} + +pub fn count_gallery_items(&self) -> Result { + let conn = self.conn.lock().unwrap(); + conn.query_row("SELECT COUNT(*) FROM gallery_meta", [], |row| row.get(0)) +} +``` + +- [ ] **Step 4: Add gallery_meta update and delete methods** + +```rust +pub fn update_gallery_meta(&self, item_id: &str, item_type: &str, collection_id: Option<&str>, tags: &str, flag: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE gallery_meta SET collection_id = ?1, tags = ?2, flag = ?3 WHERE item_id = ?4 AND item_type = ?5", + params![collection_id, tags, flag, item_id, item_type], + )?; + Ok(()) +} + +pub fn delete_gallery_meta(&self, item_id: &str, item_type: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM gallery_meta WHERE item_id = ?1 AND item_type = ?2", + params![item_id, item_type], + )?; + Ok(()) +} +``` + +- [ ] **Step 5: Add collection CRUD methods** + +```rust +pub fn insert_collection(&self, collection: &Collection) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO collections (id, name, color, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![collection.id, collection.name, collection.color, collection.position, collection.created_at], + )?; + Ok(()) +} + +pub fn list_collections(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, name, color, position, created_at FROM collections ORDER BY position ASC" + )?; + let rows = stmt.query_map([], |row| { + Ok(Collection { + id: row.get(0)?, + name: row.get(1)?, + color: row.get(2)?, + position: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + rows.collect() +} + +pub fn delete_collection(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute("UPDATE gallery_meta SET collection_id = NULL WHERE collection_id = ?1", params![id])?; + conn.execute("DELETE FROM collections WHERE id = ?1", params![id])?; + Ok(()) +} +``` + +- [ ] **Step 6: Add legal consent methods** + +```rust +pub fn record_consent(&self, tos_version: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO legal_consent (tos_version, accepted_at) VALUES (?1, ?2)", + params![tos_version, now], + )?; + Ok(()) +} + +pub fn has_valid_consent(&self, current_tos_version: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM legal_consent WHERE tos_version = ?1", + params![current_tos_version], + |row| row.get(0), + )?; + Ok(count > 0) +} +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/desktop/src-tauri/src/db.rs +git commit -m "feat(db): add gallery CRUD, collection CRUD, and legal consent methods" +``` + +--- + +### Task 3: Settings Extension & License Module + +**Files:** +- Modify: `apps/desktop/src-tauri/src/settings.rs` +- Create: `apps/desktop/src-tauri/src/license.rs` +- Modify: `apps/desktop/src-tauri/src/lib.rs` + +- [ ] **Step 1: Extend AppSettings struct** + +In `settings.rs`, add these fields to `AppSettings`: + +```rust +pub license_key: String, +pub pro_since: String, +pub gallery_view: String, +``` + +Update `get_settings()` to read these from DB (following the existing pattern of `get_or_default`). Update `update_settings()` to write them. + +- [ ] **Step 2: Create license.rs** + +```rust +use serde::{Deserialize, Serialize}; + +const LEMONSQUEEZY_API_URL: &str = "https://api.lemonsqueezy.com/v1/licenses/activate"; + +#[derive(Debug, Serialize)] +struct ActivateRequest { + license_key: String, + instance_name: String, +} + +#[derive(Debug, Deserialize)] +struct LemonSqueezyResponse { + activated: bool, + error: Option, + license_key: Option, +} + +#[derive(Debug, Deserialize)] +struct LicenseKeyInfo { + status: String, + activation_limit: Option, + activation_usage: Option, +} + +#[derive(Debug, Serialize)] +pub struct ActivationResult { + pub success: bool, + pub error: Option, + pub activations_used: Option, + pub activations_limit: Option, +} + +pub async fn activate_license(license_key: &str) -> Result { + let instance_name = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + let client = reqwest::Client::new(); + let resp = client + .post(LEMONSQUEEZY_API_URL) + .header("Accept", "application/json") + .form(&[ + ("license_key", license_key), + ("instance_name", &instance_name), + ]) + .send() + .await + .map_err(|e| format!("Network error: {}. Your key has been saved and will be validated on next launch.", e))?; + + let body: LemonSqueezyResponse = resp.json().await.map_err(|e| e.to_string())?; + + if body.activated { + Ok(ActivationResult { + success: true, + error: None, + activations_used: body.license_key.as_ref().and_then(|k| k.activation_usage), + activations_limit: body.license_key.as_ref().and_then(|k| k.activation_limit), + }) + } else { + Ok(ActivationResult { + success: false, + error: body.error.or(Some("Activation failed".to_string())), + activations_used: None, + activations_limit: None, + }) + } +} +``` + +- [ ] **Step 3: Add license module to lib.rs** + +Add `mod license;` to the module list in `lib.rs`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src-tauri/src/settings.rs apps/desktop/src-tauri/src/license.rs apps/desktop/src-tauri/src/lib.rs +git commit -m "feat: add license key validation module and extend settings" +``` + +--- + +### Task 4: Backend Commands — Gallery, License, Legal, Pro Gating + +**Files:** +- Modify: `apps/desktop/src-tauri/src/commands.rs` +- Modify: `apps/desktop/src-tauri/src/lib.rs` + +- [ ] **Step 1: Add gallery commands** + +```rust +#[tauri::command] +pub fn list_gallery(limit: Option, offset: Option, state: State<'_, AppState>) -> Result, String> { + state.db.list_gallery_items(limit.unwrap_or(50), offset.unwrap_or(0)) + .map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn gallery_count(state: State<'_, AppState>) -> Result { + state.db.count_gallery_items().map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn update_gallery_item(item_id: String, item_type: String, collection_id: Option, tags: String, flag: String, state: State<'_, AppState>) -> Result<(), String> { + state.db.update_gallery_meta(&item_id, &item_type, collection_id.as_deref(), &tags, &flag) + .map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn create_collection(name: String, color: Option, state: State<'_, AppState>) -> Result { + let collection = crate::db::Collection { + id: uuid::Uuid::new_v4().to_string(), + name, + color, + position: 0, + created_at: chrono::Utc::now().to_rfc3339(), + }; + state.db.insert_collection(&collection).map_err(|e| format!("DB error: {}", e))?; + Ok(collection) +} + +#[tauri::command] +pub fn list_collections(state: State<'_, AppState>) -> Result, String> { + state.db.list_collections().map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn delete_collection_cmd(id: String, state: State<'_, AppState>) -> Result<(), String> { + state.db.delete_collection(&id).map_err(|e| format!("DB error: {}", e)) +} +``` + +- [ ] **Step 2: Add license activation command** + +```rust +#[tauri::command] +pub async fn activate_license(license_key: String, state: State<'_, AppState>) -> Result { + let result = crate::license::activate_license(&license_key).await?; + if result.success { + let mut settings = crate::settings::get_settings(&state.db)?; + settings.pro_unlocked = true; + settings.license_key = license_key; + settings.pro_since = chrono::Utc::now().to_rfc3339(); + crate::settings::update_settings(&state.db, &settings)?; + } + Ok(result) +} +``` + +- [ ] **Step 3: Add legal consent commands** + +```rust +const TOS_VERSION: &str = "1.0"; + +#[tauri::command] +pub fn check_consent(state: State<'_, AppState>) -> Result { + state.db.has_valid_consent(TOS_VERSION).map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn accept_consent(state: State<'_, AppState>) -> Result<(), String> { + state.db.record_consent(TOS_VERSION).map_err(|e| format!("DB error: {}", e)) +} +``` + +- [ ] **Step 4: Add Pro gating to video/audio quality commands** + +In `start_video_download`, add at the top of the function. Check the actual function signature first — parameters may be `Option` or `String` depending on the existing code. Add: + +```rust +let app_settings = crate::settings::get_settings(&state.db)?; +if !app_settings.pro_unlocked { + // Enforce free tier: max 720p + if let Some(ref q) = quality { + if q == "4k" || q == "1080p" { + return Err("Pro required for 1080p and 4K quality".to_string()); + } + } +} +``` + +In `start_audio_download`, add similar gating. Check the actual parameter types (`format` and `quality` may be `Option` or `String`): + +```rust +let app_settings = crate::settings::get_settings(&state.db)?; +if !app_settings.pro_unlocked { + // Enforce free tier: MP3 only, max 192kbps + if let Some(ref f) = format { + if f != "mp3" { + return Err("Pro required for FLAC, WAV, AAC, and Opus formats".to_string()); + } + } + if let Some(ref q) = quality { + if q == "0" { // yt-dlp quality 0 = best = 320kbps + return Err("Pro required for 320kbps quality".to_string()); + } + } +} +``` + +- [ ] **Step 5: Add Pro gating to batch, scheduling, and monitor commands** + +Add to the top of `create_schedule`, `create_monitor`, and any batch operation commands: + +```rust +let app_settings = crate::settings::get_settings(&state.db)?; +if !app_settings.pro_unlocked { + return Err("Pro required for this feature".to_string()); +} +``` + +- [ ] **Step 5b: Wire gallery_meta cleanup into existing delete commands** + +In `delete_download` command, add after the existing delete call: + +```rust +let _ = state.db.delete_gallery_meta(&id, "download"); +``` + +In `delete_clip` command, add after the existing delete call: + +```rust +let _ = state.db.delete_gallery_meta(&id, "clip"); +``` + +- [ ] **Step 6: Register all new commands in lib.rs** + +Add to the `invoke_handler` macro: + +```rust +commands::list_gallery, +commands::gallery_count, +commands::update_gallery_item, +commands::create_collection, +commands::list_collections, +commands::delete_collection_cmd, +commands::activate_license, +commands::check_consent, +commands::accept_consent, +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/desktop/src-tauri/src/commands.rs apps/desktop/src-tauri/src/lib.rs +git commit -m "feat: add gallery, license, legal consent commands and Pro gating" +``` + +--- + +## Phase 2: Frontend Foundation (Tasks 5-8) + +### Task 5: TypeScript API Bindings & Hooks + +**Files:** +- Modify: `apps/desktop/src/lib/tauri.ts` +- Create: `apps/desktop/src/hooks/usePro.ts` +- Create: `apps/desktop/src/hooks/useGallery.ts` + +- [ ] **Step 1: Add new interfaces to tauri.ts** + +```typescript +export interface GalleryItem { + item_id: string; + item_type: "download" | "clip"; + title: string; + url: string; + source_type: string; + collection_id: string | null; + tags: string; + flag: string; + added_at: string; + created_at: string; +} + +export interface Collection { + id: string; + name: string; + color: string | null; + position: number; + created_at: string; +} + +export interface ActivationResult { + success: boolean; + error: string | null; + activations_used: number | null; + activations_limit: number | null; +} +``` + +- [ ] **Step 2: Add new API methods to tauri.ts** + +```typescript +// Gallery +listGallery: (limit?: number, offset?: number) => invoke("list_gallery", { limit, offset }), +galleryCount: () => invoke("gallery_count"), +updateGalleryItem: (itemId: string, itemType: string, collectionId: string | null, tags: string, flag: string) => + invoke("update_gallery_item", { itemId, itemType, collectionId, tags, flag }), + +// Collections +createCollection: (name: string, color?: string) => invoke("create_collection", { name, color }), +listCollections: () => invoke("list_collections"), +deleteCollection: (id: string) => invoke("delete_collection_cmd", { id }), + +// License +activateLicense: (licenseKey: string) => invoke("activate_license", { licenseKey }), + +// Legal +checkConsent: () => invoke("check_consent"), +acceptConsent: () => invoke("accept_consent"), +``` + +- [ ] **Step 3: Add license_key, pro_since, gallery_view to AppSettings interface** + +In `apps/desktop/src/lib/tauri.ts`, add to `AppSettings`: + +```typescript +export interface AppSettings { + // ... existing fields ... + license_key: string; + pro_since: string; + gallery_view: string; +} +``` + +Also update `DEFAULT_SETTINGS` in `apps/desktop/src/hooks/useSettings.ts` to include the new fields with defaults: + +```typescript +license_key: "", +pro_since: "", +gallery_view: "grid", +``` + +This prevents partial settings updates from overwriting these fields with undefined. + +- [ ] **Step 4: Create usePro.ts hook** + +```typescript +import { useSettings } from "./useSettings"; + +export function usePro() { + const { settings, loading } = useSettings(); + return { + isPro: settings?.pro_unlocked ?? false, + proSince: settings?.pro_since || null, + loading, + }; +} +``` + +- [ ] **Step 5: Create useGallery.ts hook** + +```typescript +import { useState, useEffect, useCallback } from "react"; +import { api, GalleryItem, Collection } from "../lib/tauri"; + +export function useGallery() { + const [items, setItems] = useState([]); + const [collections, setCollections] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const [galleryItems, galleryCount, cols] = await Promise.all([ + api.listGallery(50, 0), + api.galleryCount(), + api.listCollections(), + ]); + setItems(galleryItems); + setCount(galleryCount); + setCollections(cols); + } catch (e) { + console.error("Gallery error:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const loadMore = useCallback(async () => { + const more = await api.listGallery(50, items.length); + setItems(prev => [...prev, ...more]); + }, [items.length]); + + return { items, collections, count, loading, refresh, loadMore }; +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/desktop/src/lib/tauri.ts apps/desktop/src/hooks/usePro.ts apps/desktop/src/hooks/useGallery.ts +git commit -m "feat: add gallery/license/consent API bindings and usePro/useGallery hooks" +``` + +--- + +### Task 6: Shared Pro Components + +**Files:** +- Create: `apps/desktop/src/components/ProBadge.tsx` +- Create: `apps/desktop/src/components/ProOverlay.tsx` +- Create: `apps/desktop/src/components/LegalConsent.tsx` +- Create: `apps/desktop/src/components/ConfettiCelebration.tsx` + +- [ ] **Step 1: Create ProBadge.tsx** + +Small "Pro" pill used next to locked features. + +```tsx +import React from "react"; +import { Crown } from "lucide-react"; + +interface ProBadgeProps { + onClick?: () => void; + size?: "sm" | "md"; +} + +export function ProBadge({ onClick, size = "sm" }: ProBadgeProps) { + const cls = size === "sm" + ? "text-[10px] px-1.5 py-0.5 gap-0.5" + : "text-xs px-2 py-1 gap-1"; + return ( + + + Pro + + ); +} +``` + +- [ ] **Step 2: Create ProOverlay.tsx** + +Overlay for Pro-locked pages (scheduler, wget builder). + +```tsx +import React from "react"; +import { Lock } from "lucide-react"; +import { ProBadge } from "./ProBadge"; + +interface ProOverlayProps { + feature: string; + description: string; + onUpgrade: () => void; +} + +export function ProOverlay({ feature, description, onUpgrade }: ProOverlayProps) { + return ( +
+
+ +
+

{feature}

+

{description}

+ +

One-time purchase. No subscription. Ever.

+
+ ); +} +``` + +- [ ] **Step 3: Create LegalConsent.tsx** + +First-launch agreement screen. + +```tsx +import React, { useState } from "react"; +import { Shield } from "lucide-react"; + +interface LegalConsentProps { + onAccept: () => void; +} + +export function LegalConsent({ onAccept }: LegalConsentProps) { + const [agreed, setAgreed] = useState(false); + + return ( +
+
+
+
+ +
+

Welcome to Yoinkit

+

Your personal web toolkit

+
+ +
+

Yoinkit is a personal web toolkit for saving content to your own device.

+

You are responsible for ensuring you have the right to save content you download.

+

Respect creators — credit original sources, do not redistribute copyrighted material.

+
+ + + + +
+
+ ); +} +``` + +- [ ] **Step 4: Create ConfettiCelebration.tsx** + +Minimal confetti using CSS animations (no dependencies). + +```tsx +import React, { useEffect, useState } from "react"; + +export function ConfettiCelebration() { + const [particles, setParticles] = useState<{ id: number; left: number; color: string; delay: number }[]>([]); + + useEffect(() => { + const colors = ["#E8913A", "#FFD700", "#FF6B6B", "#4ECDC4", "#45B7D1"]; + const p = Array.from({ length: 40 }, (_, i) => ({ + id: i, + left: Math.random() * 100, + color: colors[Math.floor(Math.random() * colors.length)], + delay: Math.random() * 0.5, + })); + setParticles(p); + const timer = setTimeout(() => setParticles([]), 2500); + return () => clearTimeout(timer); + }, []); + + if (particles.length === 0) return null; + + return ( +
+ {particles.map((p) => ( +
+ ))} + +
+ ); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/components/ProBadge.tsx apps/desktop/src/components/ProOverlay.tsx apps/desktop/src/components/LegalConsent.tsx apps/desktop/src/components/ConfettiCelebration.tsx +git commit -m "feat: add ProBadge, ProOverlay, LegalConsent, and ConfettiCelebration components" +``` + +--- + +### Task 7: Navigation Rename & Legal Consent Gate + +**Files:** +- Modify: `apps/desktop/src/App.tsx` + +- [ ] **Step 1: Update Page type and NAV_ITEMS** + +Change the `Page` type: + +```typescript +type Page = "yoinks" | "gallery" | "video" | "audio" | "images" | "clipper" | "archive" | "search" | "ai" | "pro" | "settings"; +``` + +Update `NAV_ITEMS` — rename "Downloads" to "Yoinks", add Gallery between Yoinks and Video: + +```typescript +import { Download, LayoutGrid, Video, Music, ImageIcon, Scissors, Archive, Search, Brain, Zap, Settings } from "lucide-react"; + +const NAV_ITEMS: { id: Page; label: string; icon: React.ComponentType }[] = [ + { id: "yoinks", label: "Yoinks", icon: Download }, + { id: "gallery", label: "Gallery", icon: LayoutGrid }, + { id: "video", label: "Video", icon: Video }, + { id: "audio", label: "Audio", icon: Music }, + { id: "images", label: "Images", icon: ImageIcon }, + { id: "clipper", label: "Clipper", icon: Scissors }, + { id: "archive", label: "Archive", icon: Archive }, + { id: "search", label: "Search", icon: Search }, + { id: "ai", label: "Ask", icon: Brain }, + { id: "pro", label: "Pro", icon: Zap }, + { id: "settings", label: "Settings", icon: Settings }, +]; +``` + +- [ ] **Step 2: Update default page and page routing** + +Change initial state: `useState("yoinks")`. + +Update the page rendering switch to use `"yoinks"` instead of `"simple"`: + +```typescript +{activePage === "yoinks" && } +{activePage === "gallery" && } +``` + +- [ ] **Step 3: Add legal consent gate** + +Add `useEffect` to the React import if not already present. Import LegalConsent and api. Add consent state and check: + +```typescript +import { LegalConsent } from "./components/LegalConsent"; +import { api } from "./lib/tauri"; + +// Inside App component: +const [consentChecked, setConsentChecked] = useState(false); +const [hasConsent, setHasConsent] = useState(true); // default true to avoid flash + +useEffect(() => { + api.checkConsent().then(ok => { + setHasConsent(ok); + setConsentChecked(true); + }).catch(() => setConsentChecked(true)); +}, []); + +const handleAcceptConsent = async () => { + await api.acceptConsent(); + setHasConsent(true); +}; + +// In render, before the main layout: +if (consentChecked && !hasConsent) { + return ; +} +``` + +- [ ] **Step 4: Update version in sidebar** + +Change `v0.2.2` to `v0.3.0` in the sidebar footer. + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/App.tsx +git commit -m "feat: rename Downloads to Yoinks, add Gallery nav, add legal consent gate" +``` + +--- + +### Task 8: Gallery Page + +**Files:** +- Create: `apps/desktop/src/pages/GalleryPage.tsx` +- Create: `apps/desktop/src/components/GalleryItem.tsx` +- Create: `apps/desktop/src/components/GalleryToolbar.tsx` + +- [ ] **Step 1: Create GalleryItem.tsx** + +```tsx +import React from "react"; +import { FileText, Download, Archive, Image, Film, Music, Star, Pin, ExternalLink } from "lucide-react"; +import type { GalleryItem as GalleryItemType } from "../lib/tauri"; + +const TYPE_ICONS: Record> = { + download: Download, + article: FileText, + page: FileText, + archive: Archive, + image: Image, + video: Film, + audio: Music, +}; + +const FLAG_ICONS: Record> = { + star: Star, + pin: Pin, +}; + +interface GalleryItemProps { + item: GalleryItemType; + view: "grid" | "list"; +} + +export function GalleryItemCard({ item, view }: GalleryItemProps) { + const Icon = TYPE_ICONS[item.source_type] || Download; + const FlagIcon = FLAG_ICONS[item.flag]; + const date = new Date(item.created_at).toLocaleDateString(); + + if (view === "list") { + return ( +
+
+ +
+
+

{item.title}

+

{item.url}

+
+ {FlagIcon && } + {date} + +
+ ); + } + + return ( +
+
+ +
+

{item.title}

+
+ {date} + {FlagIcon && } +
+
+ ); +} +``` + +- [ ] **Step 2: Create GalleryToolbar.tsx** + +```tsx +import React from "react"; +import { LayoutGrid, List } from "lucide-react"; +import { usePro } from "../hooks/usePro"; +import { ProBadge } from "./ProBadge"; + +interface GalleryToolbarProps { + view: "grid" | "list"; + onViewChange: (v: "grid" | "list") => void; + count: number; + limit: number; + onNavigatePro: () => void; +} + +export function GalleryToolbar({ view, onViewChange, count, limit, onNavigatePro }: GalleryToolbarProps) { + const { isPro } = usePro(); + + return ( +
+
+

Gallery

+ {!isPro && count > 30 && ( + {count}/{limit} + )} +
+
+ {!isPro && ( + + )} +
+ + +
+
+
+ ); +} +``` + +- [ ] **Step 3: Create GalleryPage.tsx** + +```tsx +import React, { useState } from "react"; +import { LayoutGrid } from "lucide-react"; +import { useGallery } from "../hooks/useGallery"; +import { usePro } from "../hooks/usePro"; +import { useSettings } from "../hooks/useSettings"; +import { GalleryItemCard } from "../components/GalleryItem"; +import { GalleryToolbar } from "../components/GalleryToolbar"; +import { api } from "../lib/tauri"; + +const FREE_LIMIT = 50; + +interface GalleryPageProps { + onNavigate?: (page: string) => void; +} + +export function GalleryPage({ onNavigate }: GalleryPageProps) { + const { items, count, loading, loadMore } = useGallery(); + const { isPro } = usePro(); + const { settings, updateSettings } = useSettings(); + const [view, setView] = useState<"grid" | "list">((settings?.gallery_view as "grid" | "list") || "grid"); + + const handleViewChange = (v: "grid" | "list") => { + setView(v); + if (settings) { + updateSettings({ ...settings, gallery_view: v }); + } + }; + + const displayItems = isPro ? items : items.slice(0, FREE_LIMIT); + const isFull = !isPro && count >= FREE_LIMIT; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ onNavigate?.("pro")} + /> + + {isFull && ( +
+ Gallery full · {count}/{FREE_LIMIT} items · Upgrade for unlimited + collections + +
+ )} + + {displayItems.length === 0 ? ( +
+ +

No yoinks yet

+

Download, clip, or archive something to see it here

+
+ ) : ( + <> +
+ {displayItems.map((item) => ( + + ))} +
+ {isPro && items.length < count && ( + + )} + + )} +
+ ); +} +``` + +- [ ] **Step 4: Import GalleryPage in App.tsx** + +Add `import GalleryPage from "./pages/GalleryPage";` and render it for the `"gallery"` page case. Pass `onNavigate={setActivePage}`. + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/pages/GalleryPage.tsx apps/desktop/src/components/GalleryItem.tsx apps/desktop/src/components/GalleryToolbar.tsx apps/desktop/src/App.tsx +git commit -m "feat: add Gallery page with grid/list view and free tier limit" +``` + +--- + +## Phase 3: Pro Gating & Upgrade UX (Tasks 9-12) + +### Task 9: Video & Audio Page Pro Gating + +**Files:** +- Modify: `apps/desktop/src/pages/VideoPage.tsx` +- Modify: `apps/desktop/src/pages/AudioPage.tsx` + +- [ ] **Step 1: Add Pro gating to VideoPage quality selector** + +Import `usePro` and `ProBadge`. Wrap quality buttons: + +```tsx +const { isPro } = usePro(); +const freeQualities = ["720p", "480p", "360p"]; +const proQualities = ["4k", "1080p"]; + +// In the quality selector: +{["4k", "1080p", "720p", "480p", "360p"].map(q => { + const isLocked = !isPro && proQualities.includes(q); + return ( + + ); +})} +``` + +- [ ] **Step 2: Add legal info icon to VideoPage** + +Below the URL input, add: + +```tsx +import { Info } from "lucide-react"; + +

+ + Ensure you have permission to download this content. +

+``` + +- [ ] **Step 3: Add Pro gating to AudioPage format and quality selectors** + +Import `usePro` and `ProBadge`. Gate formats: + +```tsx +const { isPro } = usePro(); +const freeFormats = ["mp3"]; +const proFormats = ["aac", "flac", "wav", "opus"]; + +// Format selector: +{formats.map(f => { + const isLocked = !isPro && proFormats.includes(f); + return ( + + ); +})} +``` + +Gate quality — only allow 192kbps and 128kbps for free (not 320kbps): + +```tsx +{qualities.map(q => { + const isLocked = !isPro && q.value === "0"; // 320kbps + return ( + + ); +})} +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src/pages/VideoPage.tsx apps/desktop/src/pages/AudioPage.tsx +git commit -m "feat: add Pro gating to video quality and audio format/quality selectors" +``` + +--- + +### Task 10: Pro Page Rewrite + +**Files:** +- Modify: `apps/desktop/src/pages/ProPage.tsx` + +- [ ] **Step 1: Rewrite ProPage — free state (shop window)** + +Replace the current locked view with the marketing page from the spec: + +```tsx +import React, { useState } from "react"; +import { Crown, Zap, Video, Music, LayoutGrid, Layers, Gauge, Calendar, Bot, Terminal, Search, CheckCircle2 } from "lucide-react"; +import { usePro } from "../hooks/usePro"; +import { useSettings } from "../hooks/useSettings"; +import { api } from "../lib/tauri"; +import { ConfettiCelebration } from "../components/ConfettiCelebration"; + +export function ProPage() { + const { isPro, proSince } = usePro(); + const { settings } = useSettings(); + const [licenseKey, setLicenseKey] = useState(""); + const [activating, setActivating] = useState(false); + const [error, setError] = useState(""); + const [showConfetti, setShowConfetti] = useState(false); + + const handleActivate = async () => { + if (!licenseKey.trim()) return; + setActivating(true); + setError(""); + try { + const result = await api.activateLicense(licenseKey.trim()); + if (result.success) { + setShowConfetti(true); + } else { + setError(result.error || "Activation failed"); + } + } catch (e: any) { + setError(e.toString()); + } finally { + setActivating(false); + } + }; + + if (isPro) { + // Pro dashboard + return ( +
+
+
+ +
+
+

Pro

+ {proSince &&

Member since {new Date(proSince).toLocaleDateString()}

} +
+
+

All Pro features are unlocked. Enjoy the full toolkit.

+ {/* Pro feature quick links could go here */} +
+ ); + } + + // Free state — shop window + const features = [ + { icon: Video, title: "4K & 1080p Video", desc: "Download in full quality, any format" }, + { icon: Music, title: "Lossless Audio", desc: "FLAC, WAV, AAC, Opus, 320kbps" }, + { icon: LayoutGrid, title: "Unlimited Gallery", desc: "Collections, tags, flags, smart folders" }, + { icon: Layers, title: "Batch Operations", desc: "Download, clip, and export in bulk" }, + { icon: Gauge, title: "Multi-Thread Downloads", desc: "Parallel chunked downloading" }, + { icon: Calendar, title: "Scheduling", desc: "Download scheduler & site monitoring" }, + { icon: Bot, title: "MCP Server", desc: "Claude Desktop integration" }, + { icon: Terminal, title: "Wget Builder", desc: "Visual command builder & presets" }, + { icon: Search, title: "Advanced Search", desc: "Regex, filters, saved searches" }, + ]; + + return ( +
+ {showConfetti && } + + {/* Hero */} +
+
+ +
+

Unlock the full toolkit

+

£19 one-time purchase · Yours forever

+
+ + {/* Feature grid */} +
+ {features.map(({ icon: Icon, title, desc }) => ( +
+ +

{title}

+

{desc}

+
+ ))} +
+ + {/* CTA */} +
+ + Upgrade to Pro · £19 + +

One-time purchase. No subscription. Ever.

+
+ + {/* License key input */} +
+

Already have a license key?

+
+ setLicenseKey(e.target.value)} + placeholder="Paste your license key" + className="flex-1 px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm" + /> + +
+ {error &&

{error}

} +
+
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/desktop/src/pages/ProPage.tsx +git commit -m "feat: rewrite Pro page — shop window for free, dashboard for Pro" +``` + +--- + +### Task 11: Settings Page — License Key & Legal Links + +**Files:** +- Modify: `apps/desktop/src/pages/SettingsPage.tsx` + +- [ ] **Step 1: Add license key section to Settings** + +In the settings page, replace the current Pro status display with a license key management section: + +```tsx +{/* Pro & License */} +
+

Pro License

+ {settings.pro_unlocked ? ( +
+ + Pro Unlocked + {settings.pro_since && since {new Date(settings.pro_since).toLocaleDateString()}} +
+ ) : ( +
+ Free Plan + +
+ )} +
+``` + +- [ ] **Step 2: Add Legal section to Settings** + +```tsx +{/* Legal */} +
+

Legal

+
+ Terms of Use + Privacy Policy + DMCA Policy +

copyright@yoinkit.app

+
+
+``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src/pages/SettingsPage.tsx +git commit -m "feat: add license key management and legal links to Settings" +``` + +--- + +### Task 12: In-App Legal Touchpoints + +**Files:** +- Modify: `apps/desktop/src/pages/ClipperPage.tsx` +- Modify: `apps/desktop/src/pages/ArchivePage.tsx` + +- [ ] **Step 1: Add legal info to ClipperPage** + +Add below the URL input area: + +```tsx +import { Info } from "lucide-react"; + +

+ + Clips are saved locally for personal reference. Credit original creators when sharing. +

+``` + +- [ ] **Step 2: Add legal info to ArchivePage** + +Add below the URL input area: + +```tsx +

+ + Archives are for personal offline access. Fair dealing applies to research and private study. +

+``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src/pages/ClipperPage.tsx apps/desktop/src/pages/ArchivePage.tsx +git commit -m "feat: add legal info touchpoints to Clipper and Archive pages" +``` + +--- + +## Phase 4: Final Integration (Tasks 13-14) + +### Task 13: Pro Gating for Existing Pro-Only Pages + +**Files:** +- Modify: `apps/desktop/src/pages/ProPage.tsx` (wget builder section) + +The current ProPage already gates the wget builder behind `pro_unlocked`. Since we rewrote it in Task 10, the wget builder, batch input, and preset manager now need to be accessible from the Pro dashboard when unlocked. The shop window replaces the locked view. + +- [ ] **Step 1: Add wget builder, batch, and presets to Pro dashboard** + +In the Pro dashboard (unlocked state) section of ProPage.tsx, add tabs for the existing functionality: + +```tsx +if (isPro) { + return ( +
+ {/* Header from Task 10 */} + + {/* Tab bar */} +
+ + +
+ + {/* Existing components */} + {tab === "single" ? ( + <> + + + + ) : ( + + )} + + + + +
+ ); +} +``` + +Import the existing components: `CommandBuilder`, `CommandPreview`, `BatchInput`, `PresetManager`, `DownloadList`. Preserve the existing state and handlers from the current ProPage. + +- [ ] **Step 2: Commit** + +```bash +git add apps/desktop/src/pages/ProPage.tsx +git commit -m "feat: integrate wget builder, batch, presets into Pro dashboard" +``` + +--- + +### Task 14: Version Bump & Push + +**Files:** +- Modify: `apps/desktop/src-tauri/tauri.conf.json` + +- [ ] **Step 1: Update version in tauri.conf.json** + +Change `"version": "0.1.0"` to `"version": "0.3.0"`. + +- [ ] **Step 2: Merge to main and tag** + +```bash +git checkout main +git merge --ff-only v2-features +git tag -d v0.3.0 +git push origin :refs/tags/v0.3.0 +git tag v0.3.0 -m "v0.3.0: Pro tier, Gallery, legal compliance, license validation" +git push origin main --tags +git checkout v2-features +``` + +- [ ] **Step 3: Monitor CI build** + +```bash +gh run list --limit 4 +# Wait for Build Yoinkit to complete +gh run view --log-failed # If it fails +``` + +- [ ] **Step 4: Verify release** + +```bash +gh release view v0.3.0 +# Should show Yoinkit_0.3.0_aarch64.dmg and Yoinkit_0.3.0_x64.dmg +``` + +--- + +## Task Dependency Graph + +``` +Phase 1 (Backend): Task 1 → Task 2 → Task 3 → Task 4 +Phase 2 (Frontend): Task 5 → Task 6 → Task 7 → Task 8 +Phase 3 (Pro UX): Task 9, Task 10, Task 11, Task 12 (parallel after Phase 2) +Phase 4 (Integration): Task 13 → Task 14 + +Phase 1 and Phase 2 can run in parallel (backend vs frontend). +Phase 3 depends on both Phase 1 and Phase 2. +Phase 4 depends on Phase 3. +``` + +## Parallel Execution Opportunities + +| Batch | Tasks | Reason | +|---|---|---| +| Batch A | Task 1 + Task 6 | Backend DB migration + Frontend components (no dependencies) | +| Batch B | Task 2 + Task 3 | Gallery CRUD + Settings/license module (different sections of db.rs — may need sequential if conflict) | +| Batch C | Task 9 + Task 10 + Task 11 + Task 12 | All modify different page files | + +Note: Tasks 5 and 7 depend on Task 3 (AppSettings fields must exist in Rust before TypeScript interfaces are updated). Phase 2 tasks are sequential: 5 → 6 → 7 → 8. + +--- + +## Deferred to Future Plan + +The following Pro Gallery features from the spec are **intentionally deferred** to keep this plan focused on the core Pro gating, gallery foundation, and legal framework. They will be implemented in a follow-up plan: + +- **Smart folders** — CRUD, rules engine, UI (tables created in migrate_v4 but no logic yet) +- **Sort by** — date, kind, size, source domain, title (gallery query currently hardcoded to date DESC) +- **Filter by** — kind, date range, tag, collection, flag +- **Hover preview** — thumbnail expand, clip snippet, audio waveform +- **Bulk actions** — multi-select, move to collection, tag, flag, delete, export +- **Tags management UI** — adding/editing tags on gallery items from gallery page +- **Pro dashboard** — quick links, gallery stats, MCP status (placeholder in this plan) +- **Scheduler/MCP/Wget builder ProOverlay usage** — created in this plan but not wired to specific pages yet From 545b9b070d5aee84883703ee9c8474882d051cbf Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:00:27 +0000 Subject: [PATCH 04/19] =?UTF-8?q?feat(db):=20add=20migrate=5Fv4=20?= =?UTF-8?q?=E2=80=94=20gallery,=20collections,=20legal=20consent=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/db.rs | 57 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 6f4ae4d..1f8a822 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ scraper = "0.21" reqwest = { version = "0.12", features = ["json", "native-tls-vendored"] } sha2 = "0.10" tantivy = "0.22" +hostname = "0.4" [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/apps/desktop/src-tauri/src/db.rs b/apps/desktop/src-tauri/src/db.rs index 688c3ab..f7b8f3c 100644 --- a/apps/desktop/src-tauri/src/db.rs +++ b/apps/desktop/src-tauri/src/db.rs @@ -112,6 +112,9 @@ impl Database { if current_version < 3 { Self::migrate_v3(&conn)?; } + if current_version < 4 { + Self::migrate_v4(&conn)?; + } let db = Self { conn: Mutex::new(conn) }; db.init_default_settings()?; @@ -143,6 +146,9 @@ impl Database { if current_version < 3 { Self::migrate_v3(&conn)?; } + if current_version < 4 { + Self::migrate_v4(&conn)?; + } let db = Self { conn: Mutex::new(conn) }; db.init_default_settings()?; @@ -258,6 +264,54 @@ impl Database { Ok(()) } + fn migrate_v4(conn: &Connection) -> Result<()> { + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS collections ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + color TEXT, + position INTEGER DEFAULT 0, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS gallery_meta ( + item_id TEXT NOT NULL, + item_type TEXT NOT NULL, + collection_id TEXT, + tags TEXT DEFAULT '', + flag TEXT DEFAULT '', + position INTEGER DEFAULT 0, + added_at TEXT NOT NULL, + PRIMARY KEY (item_id, item_type), + FOREIGN KEY (collection_id) REFERENCES collections(id) + ); + + CREATE TABLE IF NOT EXISTS smart_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + rules_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS legal_consent ( + id INTEGER PRIMARY KEY, + tos_version TEXT NOT NULL, + accepted_at TEXT NOT NULL + ); + + INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) + SELECT id, 'download', created_at FROM downloads; + + INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) + SELECT id, 'clip', created_at FROM clips; + ")?; + conn.execute( + "INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (4, datetime('now'))", + [], + )?; + Ok(()) + } + fn init_default_settings(&self) -> Result<()> { let conn = self.conn.lock().unwrap(); let defaults = vec![ @@ -279,6 +333,9 @@ impl Database { ("ai_model", "".to_string()), ("clip_on_download", "false".to_string()), ("bandwidth_limit", "0".to_string()), + ("license_key", "".to_string()), + ("pro_since", "".to_string()), + ("gallery_view", "grid".to_string()), ]; for (key, value) in defaults { conn.execute( From 8c254949fade980c3830f1d49df3c9093c8845e3 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:03:13 +0000 Subject: [PATCH 05/19] feat: add ProBadge, ProOverlay, LegalConsent, and ConfettiCelebration components Co-Authored-By: Claude Opus 4.6 --- .../src/components/ConfettiCelebration.tsx | 44 ++++++++++++++ apps/desktop/src/components/LegalConsent.tsx | 58 +++++++++++++++++++ apps/desktop/src/components/ProBadge.tsx | 26 +++++++++ apps/desktop/src/components/ProOverlay.tsx | 28 +++++++++ 4 files changed, 156 insertions(+) create mode 100644 apps/desktop/src/components/ConfettiCelebration.tsx create mode 100644 apps/desktop/src/components/LegalConsent.tsx create mode 100644 apps/desktop/src/components/ProBadge.tsx create mode 100644 apps/desktop/src/components/ProOverlay.tsx diff --git a/apps/desktop/src/components/ConfettiCelebration.tsx b/apps/desktop/src/components/ConfettiCelebration.tsx new file mode 100644 index 0000000..dfb3e4e --- /dev/null +++ b/apps/desktop/src/components/ConfettiCelebration.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useState } from "react"; + +export function ConfettiCelebration() { + const [particles, setParticles] = useState<{ id: number; left: number; color: string; delay: number }[]>([]); + + useEffect(() => { + const colors = ["#E8913A", "#FFD700", "#FF6B6B", "#4ECDC4", "#45B7D1"]; + const p = Array.from({ length: 40 }, (_, i) => ({ + id: i, + left: Math.random() * 100, + color: colors[Math.floor(Math.random() * colors.length)], + delay: Math.random() * 0.5, + })); + setParticles(p); + const timer = setTimeout(() => setParticles([]), 2500); + return () => clearTimeout(timer); + }, []); + + if (particles.length === 0) return null; + + return ( +
+ {particles.map((p) => ( +
+ ))} + +
+ ); +} diff --git a/apps/desktop/src/components/LegalConsent.tsx b/apps/desktop/src/components/LegalConsent.tsx new file mode 100644 index 0000000..6f7ab32 --- /dev/null +++ b/apps/desktop/src/components/LegalConsent.tsx @@ -0,0 +1,58 @@ +import React, { useState } from "react"; +import { Shield } from "lucide-react"; + +interface LegalConsentProps { + onAccept: () => void; +} + +export function LegalConsent({ onAccept }: LegalConsentProps) { + const [agreed, setAgreed] = useState(false); + + return ( +
+
+
+
+ +
+

Welcome to Yoinkit

+

Your personal web toolkit

+
+ +
+

Yoinkit is a personal web toolkit for saving content to your own device.

+

You are responsible for ensuring you have the right to save content you download.

+

Respect creators — credit original sources, do not redistribute copyrighted material.

+
+ + + + +
+
+ ); +} diff --git a/apps/desktop/src/components/ProBadge.tsx b/apps/desktop/src/components/ProBadge.tsx new file mode 100644 index 0000000..2ceb471 --- /dev/null +++ b/apps/desktop/src/components/ProBadge.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Crown } from "lucide-react"; + +interface ProBadgeProps { + onClick?: () => void; + size?: "sm" | "md"; +} + +export function ProBadge({ onClick, size = "sm" }: ProBadgeProps) { + const cls = size === "sm" + ? "text-[10px] px-1.5 py-0.5 gap-0.5" + : "text-xs px-2 py-1 gap-1"; + return ( + + + Pro + + ); +} diff --git a/apps/desktop/src/components/ProOverlay.tsx b/apps/desktop/src/components/ProOverlay.tsx new file mode 100644 index 0000000..99e5d47 --- /dev/null +++ b/apps/desktop/src/components/ProOverlay.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Lock } from "lucide-react"; + +interface ProOverlayProps { + feature: string; + description: string; + onUpgrade: () => void; +} + +export function ProOverlay({ feature, description, onUpgrade }: ProOverlayProps) { + return ( +
+
+ +
+

{feature}

+

{description}

+ +

One-time purchase. No subscription. Ever.

+
+ ); +} From 94bf3c06ad1b686344300cc7709f34787e56ea77 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:03:29 +0000 Subject: [PATCH 06/19] feat(db): add gallery CRUD, collection CRUD, and legal consent methods Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src-tauri/src/db.rs | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/apps/desktop/src-tauri/src/db.rs b/apps/desktop/src-tauri/src/db.rs index f7b8f3c..492694a 100644 --- a/apps/desktop/src-tauri/src/db.rs +++ b/apps/desktop/src-tauri/src/db.rs @@ -82,6 +82,29 @@ pub struct Monitor { pub created_at: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct GalleryItem { + pub item_id: String, + pub item_type: String, // "download" or "clip" + pub title: String, + pub url: String, + pub source_type: String, // "download", "article", "archive", etc. + pub collection_id: Option, + pub tags: String, + pub flag: String, + pub added_at: String, + pub created_at: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Collection { + pub id: String, + pub name: String, + pub color: Option, + pub position: i32, + pub created_at: String, +} + pub struct Database { conn: Mutex, } @@ -370,6 +393,10 @@ impl Database { download.created_at, download.completed_at, download.file_hash, ], )?; + conn.execute( + "INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) VALUES (?1, 'download', ?2)", + params![download.id, download.created_at], + )?; Ok(()) } @@ -497,6 +524,10 @@ impl Database { clip.created_at, clip.updated_at, ], )?; + conn.execute( + "INSERT OR IGNORE INTO gallery_meta (item_id, item_type, added_at) VALUES (?1, 'clip', ?2)", + params![clip.id, clip.created_at], + )?; Ok(()) } @@ -716,4 +747,127 @@ impl Database { conn.execute("DELETE FROM monitors WHERE id = ?1", params![id])?; Ok(()) } + + // Gallery CRUD + pub fn list_gallery_items(&self, limit: i64, offset: i64) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT + gm.item_id, + gm.item_type, + CASE + WHEN gm.item_type = 'download' THEN REPLACE(d.save_path, RTRIM(d.save_path, REPLACE(d.save_path, '/', '')), '') + WHEN gm.item_type = 'clip' THEN COALESCE(c.title, '') + ELSE '' + END AS title, + COALESCE(d.url, c.url, '') AS url, + CASE + WHEN gm.item_type = 'download' THEN 'download' + WHEN gm.item_type = 'clip' THEN COALESCE(c.source_type, 'clip') + ELSE '' + END AS source_type, + gm.collection_id, + COALESCE(gm.tags, '') AS tags, + COALESCE(gm.flag, '') AS flag, + gm.added_at, + COALESCE(d.created_at, c.created_at, gm.added_at) AS created_at + FROM gallery_meta gm + LEFT JOIN downloads d ON gm.item_id = d.id AND gm.item_type = 'download' + LEFT JOIN clips c ON gm.item_id = c.id AND gm.item_type = 'clip' + ORDER BY gm.added_at DESC + LIMIT ?1 OFFSET ?2" + )?; + let rows = stmt.query_map(params![limit, offset], |row| { + Ok(GalleryItem { + item_id: row.get(0)?, + item_type: row.get(1)?, + title: row.get(2)?, + url: row.get(3)?, + source_type: row.get(4)?, + collection_id: row.get(5)?, + tags: row.get(6)?, + flag: row.get(7)?, + added_at: row.get(8)?, + created_at: row.get(9)?, + }) + })?; + rows.collect() + } + + pub fn count_gallery_items(&self) -> Result { + let conn = self.conn.lock().unwrap(); + conn.query_row("SELECT COUNT(*) FROM gallery_meta", [], |row| row.get(0)) + } + + pub fn update_gallery_meta(&self, item_id: &str, item_type: &str, collection_id: Option<&str>, tags: &str, flag: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE gallery_meta SET collection_id = ?3, tags = ?4, flag = ?5 WHERE item_id = ?1 AND item_type = ?2", + params![item_id, item_type, collection_id, tags, flag], + )?; + Ok(()) + } + + pub fn delete_gallery_meta(&self, item_id: &str, item_type: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM gallery_meta WHERE item_id = ?1 AND item_type = ?2", + params![item_id, item_type], + )?; + Ok(()) + } + + // Collections CRUD + pub fn insert_collection(&self, collection: &Collection) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO collections (id, name, color, position, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![collection.id, collection.name, collection.color, collection.position, collection.created_at], + )?; + Ok(()) + } + + pub fn list_collections(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, name, color, position, created_at FROM collections ORDER BY position ASC" + )?; + let rows = stmt.query_map([], |row| { + Ok(Collection { + id: row.get(0)?, name: row.get(1)?, color: row.get(2)?, + position: row.get(3)?, created_at: row.get(4)?, + }) + })?; + rows.collect() + } + + pub fn delete_collection(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE gallery_meta SET collection_id = NULL WHERE collection_id = ?1", + params![id], + )?; + conn.execute("DELETE FROM collections WHERE id = ?1", params![id])?; + Ok(()) + } + + // Legal consent + pub fn record_consent(&self, tos_version: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO legal_consent (tos_version, accepted_at) VALUES (?1, datetime('now'))", + params![tos_version], + )?; + Ok(()) + } + + pub fn has_valid_consent(&self, current_tos_version: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM legal_consent WHERE tos_version = ?1", + params![current_tos_version], + |row| row.get(0), + )?; + Ok(count > 0) + } } From bb702f927c12742d283772bc370bd9622628c080 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:08:33 +0000 Subject: [PATCH 07/19] feat: add license key validation module and extend settings Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src-tauri/src/lib.rs | 1 + apps/desktop/src-tauri/src/license.rs | 66 ++++++++++++++++++++++++++ apps/desktop/src-tauri/src/settings.rs | 9 ++++ 3 files changed, 76 insertions(+) create mode 100644 apps/desktop/src-tauri/src/license.rs diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bec54a0..e410e76 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod auth; mod commands; mod db; mod download_manager; +mod license; mod markdown; mod scheduler; mod search; diff --git a/apps/desktop/src-tauri/src/license.rs b/apps/desktop/src-tauri/src/license.rs new file mode 100644 index 0000000..b3ce1ca --- /dev/null +++ b/apps/desktop/src-tauri/src/license.rs @@ -0,0 +1,66 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +const LEMONSQUEEZY_API_URL: &str = "https://api.lemonsqueezy.com/v1/licenses/activate"; + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct LemonSqueezyResponse { + activated: bool, + error: Option, + license_key: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct LicenseKeyInfo { + status: String, + activation_limit: Option, + activation_usage: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Serialize)] +pub struct ActivationResult { + pub success: bool, + pub error: Option, + pub activations_used: Option, + pub activations_limit: Option, +} + +#[allow(dead_code)] +pub async fn activate_license(license_key: &str) -> Result { + let instance_name = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + + let client = reqwest::Client::new(); + let resp = client + .post(LEMONSQUEEZY_API_URL) + .header("Accept", "application/json") + .form(&[ + ("license_key", license_key), + ("instance_name", instance_name.as_str()), + ]) + .send() + .await + .map_err(|e| format!("Network error: {}. Your key has been saved and will be validated on next launch.", e))?; + + let body: LemonSqueezyResponse = resp.json().await.map_err(|e| e.to_string())?; + + if body.activated { + Ok(ActivationResult { + success: true, + error: None, + activations_used: body.license_key.as_ref().and_then(|k| k.activation_usage), + activations_limit: body.license_key.as_ref().and_then(|k| k.activation_limit), + }) + } else { + Ok(ActivationResult { + success: false, + error: body.error.or(Some("Activation failed".to_string())), + activations_used: None, + activations_limit: None, + }) + } +} diff --git a/apps/desktop/src-tauri/src/settings.rs b/apps/desktop/src-tauri/src/settings.rs index 45982b0..02a004e 100644 --- a/apps/desktop/src-tauri/src/settings.rs +++ b/apps/desktop/src-tauri/src/settings.rs @@ -17,6 +17,9 @@ pub struct AppSettings { pub ai_model: String, pub clip_on_download: bool, pub bandwidth_limit: u32, + pub license_key: String, + pub pro_since: String, + pub gallery_view: String, } pub fn get_settings(db: &Arc) -> Result { @@ -41,6 +44,9 @@ pub fn get_settings(db: &Arc) -> Result { ai_model: get("ai_model", ""), clip_on_download: get("clip_on_download", "false") == "true", bandwidth_limit: get("bandwidth_limit", "0").parse().unwrap_or(0), + license_key: get("license_key", ""), + pro_since: get("pro_since", ""), + gallery_view: get("gallery_view", "grid"), }) } @@ -62,5 +68,8 @@ pub fn update_settings(db: &Arc, settings: &AppSettings) -> Result<(), set("ai_model", &settings.ai_model)?; set("clip_on_download", if settings.clip_on_download { "true" } else { "false" })?; set("bandwidth_limit", &settings.bandwidth_limit.to_string())?; + set("license_key", &settings.license_key)?; + set("pro_since", &settings.pro_since)?; + set("gallery_view", &settings.gallery_view)?; Ok(()) } From d019d5231e528c84d68093569c303efc73602573 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:14:08 +0000 Subject: [PATCH 08/19] feat: add gallery, license, legal consent commands and Pro gating Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src-tauri/src/commands.rs | 116 ++++++++++++++++++++++++- apps/desktop/src-tauri/src/lib.rs | 9 ++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 18ec526..382cf62 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -67,7 +67,9 @@ pub fn list_downloads(state: State<'_, AppState>) -> Result, id: String) -> Result<(), String> { - state.download_manager.delete_download(&id) + state.download_manager.delete_download(&id)?; + let _ = state.db.delete_gallery_meta(&id, "download"); + Ok(()) } #[tauri::command] @@ -173,6 +175,16 @@ pub async fn start_video_download( sub_lang: Option, sub_format: Option, ) -> Result { + // Pro gating for high-quality video + let app_settings = crate::settings::get_settings(&state.db)?; + if !app_settings.pro_unlocked { + if let Some(ref q) = quality { + if q == "4k" || q == "1080p" { + return Err("Pro required for 1080p and 4K quality".to_string()); + } + } + } + let save_dir = save_path.unwrap_or_else(|| { state.db.get_setting("default_save_path") .ok() @@ -276,6 +288,21 @@ pub async fn start_audio_download( sub_lang: Option, sub_format: Option, ) -> Result { + // Pro gating for premium audio formats/quality + let app_settings = crate::settings::get_settings(&state.db)?; + if !app_settings.pro_unlocked { + if let Some(ref f) = format { + if f != "mp3" { + return Err("Pro required for FLAC, WAV, AAC, and Opus formats".to_string()); + } + } + if let Some(ref q) = quality { + if q == "0" { + return Err("Pro required for 320kbps quality".to_string()); + } + } + } + start_video_download(state, url, format, quality, true, save_path, write_subs, sub_lang, sub_format).await } @@ -498,7 +525,9 @@ pub fn get_clip(id: String, state: State<'_, AppState>) -> Result, #[tauri::command] pub fn delete_clip(id: String, state: State<'_, AppState>) -> Result<(), String> { - state.db.delete_clip(&id).map_err(|e| format!("DB error: {}", e)) + state.db.delete_clip(&id).map_err(|e| format!("DB error: {}", e))?; + let _ = state.db.delete_gallery_meta(&id, "clip"); + Ok(()) } #[tauri::command] @@ -738,6 +767,11 @@ pub fn export_batch_notebooklm(ids: Vec, export_dir: String, batch_name: #[tauri::command] pub fn create_monitor(url: String, state: State<'_, AppState>) -> Result { + let app_settings = crate::settings::get_settings(&state.db)?; + if !app_settings.pro_unlocked { + return Err("Pro required for this feature".to_string()); + } + let monitor = crate::db::Monitor { id: Uuid::new_v4().to_string(), url, @@ -800,6 +834,11 @@ pub async fn generate_digest(state: State<'_, AppState>) -> Result #[tauri::command] pub fn create_schedule(url: String, job_type: String, cron: String, flags: Option, state: State<'_, AppState>) -> Result { + let app_settings = crate::settings::get_settings(&state.db)?; + if !app_settings.pro_unlocked { + return Err("Pro required for this feature".to_string()); + } + let schedule = crate::db::Schedule { id: Uuid::new_v4().to_string(), url, @@ -835,3 +874,76 @@ pub fn toggle_schedule(id: String, enabled: bool, state: State<'_, AppState>) -> schedule.enabled = if enabled { 1 } else { 0 }; state.db.update_schedule(&schedule).map_err(|e| format!("DB error: {}", e)) } + +// Gallery commands + +#[tauri::command] +pub fn list_gallery(limit: Option, offset: Option, state: State<'_, AppState>) -> Result, String> { + state.db.list_gallery_items(limit.unwrap_or(50), offset.unwrap_or(0)) + .map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn gallery_count(state: State<'_, AppState>) -> Result { + state.db.count_gallery_items().map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn update_gallery_item(item_id: String, item_type: String, collection_id: Option, tags: String, flag: String, state: State<'_, AppState>) -> Result<(), String> { + state.db.update_gallery_meta(&item_id, &item_type, collection_id.as_deref(), &tags, &flag) + .map_err(|e| format!("DB error: {}", e)) +} + +// Collection commands + +#[tauri::command] +pub fn create_collection(name: String, color: Option, state: State<'_, AppState>) -> Result { + let collection = crate::db::Collection { + id: uuid::Uuid::new_v4().to_string(), + name, + color, + position: 0, + created_at: chrono::Utc::now().to_rfc3339(), + }; + state.db.insert_collection(&collection).map_err(|e| format!("DB error: {}", e))?; + Ok(collection) +} + +#[tauri::command] +pub fn list_collections_cmd(state: State<'_, AppState>) -> Result, String> { + state.db.list_collections().map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn delete_collection_cmd(id: String, state: State<'_, AppState>) -> Result<(), String> { + state.db.delete_collection(&id).map_err(|e| format!("DB error: {}", e)) +} + +// License activation + +#[tauri::command] +pub async fn activate_license(license_key: String, state: State<'_, AppState>) -> Result { + let result = crate::license::activate_license(&license_key).await?; + if result.success { + let mut settings = crate::settings::get_settings(&state.db)?; + settings.pro_unlocked = true; + settings.license_key = license_key; + settings.pro_since = chrono::Utc::now().to_rfc3339(); + crate::settings::update_settings(&state.db, &settings)?; + } + Ok(result) +} + +// Legal consent + +const TOS_VERSION: &str = "1.0"; + +#[tauri::command] +pub fn check_consent(state: State<'_, AppState>) -> Result { + state.db.has_valid_consent(TOS_VERSION).map_err(|e| format!("DB error: {}", e)) +} + +#[tauri::command] +pub fn accept_consent(state: State<'_, AppState>) -> Result<(), String> { + state.db.record_consent(TOS_VERSION).map_err(|e| format!("DB error: {}", e)) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e410e76..364e09d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -126,6 +126,15 @@ pub fn run() { commands::delete_monitor, commands::check_monitor, commands::generate_digest, + commands::list_gallery, + commands::gallery_count, + commands::update_gallery_item, + commands::create_collection, + commands::list_collections_cmd, + commands::delete_collection_cmd, + commands::activate_license, + commands::check_consent, + commands::accept_consent, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); From c0c072829cf1e96ec2de170ed3eb5600aff56575 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:20:03 +0000 Subject: [PATCH 09/19] feat: add gallery/license/consent API bindings and usePro/useGallery hooks Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/hooks/useGallery.ts | 35 +++++++++++++++++++ apps/desktop/src/hooks/usePro.ts | 10 ++++++ apps/desktop/src/hooks/useSettings.ts | 3 ++ apps/desktop/src/lib/tauri.ts | 49 +++++++++++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 apps/desktop/src/hooks/useGallery.ts create mode 100644 apps/desktop/src/hooks/usePro.ts diff --git a/apps/desktop/src/hooks/useGallery.ts b/apps/desktop/src/hooks/useGallery.ts new file mode 100644 index 0000000..b200f03 --- /dev/null +++ b/apps/desktop/src/hooks/useGallery.ts @@ -0,0 +1,35 @@ +import { useState, useEffect, useCallback } from "react"; +import { api, GalleryItem, Collection } from "../lib/tauri"; + +export function useGallery() { + const [items, setItems] = useState([]); + const [collections, setCollections] = useState([]); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const [galleryItems, galleryCount, cols] = await Promise.all([ + api.listGallery(50, 0), + api.galleryCount(), + api.listCollections(), + ]); + setItems(galleryItems); + setCount(galleryCount); + setCollections(cols); + } catch (e) { + console.error("Gallery error:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const loadMore = useCallback(async () => { + const more = await api.listGallery(50, items.length); + setItems(prev => [...prev, ...more]); + }, [items.length]); + + return { items, collections, count, loading, refresh, loadMore }; +} diff --git a/apps/desktop/src/hooks/usePro.ts b/apps/desktop/src/hooks/usePro.ts new file mode 100644 index 0000000..ba1251e --- /dev/null +++ b/apps/desktop/src/hooks/usePro.ts @@ -0,0 +1,10 @@ +import { useSettings } from "./useSettings"; + +export function usePro() { + const { settings, loading } = useSettings(); + return { + isPro: settings?.pro_unlocked ?? false, + proSince: settings?.pro_since || null, + loading, + }; +} diff --git a/apps/desktop/src/hooks/useSettings.ts b/apps/desktop/src/hooks/useSettings.ts index 68decfb..1f821ef 100644 --- a/apps/desktop/src/hooks/useSettings.ts +++ b/apps/desktop/src/hooks/useSettings.ts @@ -7,6 +7,9 @@ const DEFAULT_SETTINGS: AppSettings = { max_concurrent: 3, pro_unlocked: false, bandwidth_limit: 0, + license_key: "", + pro_since: "", + gallery_view: "grid", } as AppSettings; export function useSettings() { diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index b70b54f..06cc589 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -56,6 +56,9 @@ export interface AppSettings { ai_model: string; clip_on_download: boolean; bandwidth_limit: number; + license_key: string; + pro_since: string; + gallery_view: string; } export interface Preset { @@ -124,6 +127,34 @@ export interface Monitor { created_at: string; } +export interface GalleryItem { + item_id: string; + item_type: "download" | "clip"; + title: string; + url: string; + source_type: string; + collection_id: string | null; + tags: string; + flag: string; + added_at: string; + created_at: string; +} + +export interface Collection { + id: string; + name: string; + color: string | null; + position: number; + created_at: string; +} + +export interface ActivationResult { + success: boolean; + error: string | null; + activations_used: number | null; + activations_limit: number | null; +} + export const api = { startDownload: (url: string, flags?: WgetFlags, savePath?: string) => invoke("start_download", { url, flags, savePath }), @@ -195,4 +226,22 @@ export const api = { deleteMonitor: (id: string) => invoke("delete_monitor", { id }), checkMonitor: (id: string) => invoke("check_monitor", { id }), generateDigest: () => invoke("generate_digest"), + + // Gallery + listGallery: (limit?: number, offset?: number) => invoke("list_gallery", { limit, offset }), + galleryCount: () => invoke("gallery_count"), + updateGalleryItem: (itemId: string, itemType: string, collectionId: string | null, tags: string, flag: string) => + invoke("update_gallery_item", { itemId, itemType, collectionId, tags, flag }), + + // Collections + createCollection: (name: string, color?: string) => invoke("create_collection", { name, color }), + listCollections: () => invoke("list_collections_cmd"), + deleteCollection: (id: string) => invoke("delete_collection_cmd", { id }), + + // License + activateLicense: (licenseKey: string) => invoke("activate_license", { licenseKey }), + + // Legal + checkConsent: () => invoke("check_consent"), + acceptConsent: () => invoke("accept_consent"), }; From 8d997dc6fef46ab83ca0573182197ad14aee5b3e Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:24:09 +0000 Subject: [PATCH 10/19] feat: rename Downloads to Yoinks, add Gallery nav, add legal consent gate Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/App.tsx | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 73b4e2c..3e7b1e9 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,5 +1,5 @@ -import { useState, createContext, useContext } from "react"; -import { Download, Video, Music, ImageIcon, Zap, Settings, Sun, Moon, Monitor, Scissors, Archive, Search, type LucideProps } from "lucide-react"; +import { useState, useEffect, createContext, useContext } from "react"; +import { Download, Video, Music, ImageIcon, Zap, Settings, Sun, Moon, Monitor, Scissors, Archive, Search, Brain, LayoutGrid, type LucideProps } from "lucide-react"; import { useTheme } from "./hooks/useTheme"; import { SimplePage } from "./pages/SimplePage"; @@ -11,8 +11,11 @@ import { SettingsPage } from "./pages/SettingsPage"; import { ClipperPage } from "./pages/ClipperPage"; import { ArchivePage } from "./pages/ArchivePage"; import { SearchPage } from "./pages/SearchPage"; +import { AIPage } from "./pages/AIPage"; +import { LegalConsent } from "./components/LegalConsent"; +import { api } from "./lib/tauri"; -type Page = "simple" | "video" | "audio" | "images" | "clipper" | "archive" | "search" | "pro" | "settings"; +type Page = "yoinks" | "gallery" | "video" | "audio" | "images" | "clipper" | "archive" | "search" | "ai" | "pro" | "settings"; type Theme = "light" | "dark" | "system"; interface ThemeContextType { @@ -30,20 +33,40 @@ export const ThemeContext = createContext({ export const useThemeContext = () => useContext(ThemeContext); const NAV_ITEMS: { id: Page; label: string; icon: React.ComponentType }[] = [ - { id: "simple", label: "Downloads", icon: Download }, + { id: "yoinks", label: "Yoinks", icon: Download }, + { id: "gallery", label: "Gallery", icon: LayoutGrid }, { id: "video", label: "Video", icon: Video }, { id: "audio", label: "Audio", icon: Music }, { id: "images", label: "Images", icon: ImageIcon }, { id: "clipper", label: "Clipper", icon: Scissors }, { id: "archive", label: "Archive", icon: Archive }, { id: "search", label: "Search", icon: Search }, + { id: "ai", label: "Ask", icon: Brain }, { id: "pro", label: "Pro", icon: Zap }, { id: "settings", label: "Settings", icon: Settings }, ]; function App() { - const [page, setPage] = useState("simple"); + const [page, setPage] = useState("yoinks"); const themeState = useTheme(); + const [consentChecked, setConsentChecked] = useState(false); + const [hasConsent, setHasConsent] = useState(true); // default true to avoid flash + + useEffect(() => { + api.checkConsent().then(ok => { + setHasConsent(ok); + setConsentChecked(true); + }).catch(() => setConsentChecked(true)); + }, []); + + const handleAcceptConsent = async () => { + await api.acceptConsent(); + setHasConsent(true); + }; + + if (consentChecked && !hasConsent) { + return ; + } return ( @@ -98,19 +121,21 @@ function App() { ))}
-

v0.2.2

+

v0.3.0

{/* Main Content */}
- {page === "simple" && } + {page === "yoinks" && } + {page === "gallery" &&
Gallery coming soon
} {page === "video" && } {page === "audio" && } {page === "images" && } {page === "clipper" && } {page === "archive" && } {page === "search" && } + {page === "ai" && } {page === "pro" && } {page === "settings" && }
From fc580b5ab009c69156ded33bbf5ddd46ee1f7eea Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:28:19 +0000 Subject: [PATCH 11/19] fix: add error handling to consent accept, document fail-open behavior Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/App.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3e7b1e9..eafce68 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -56,12 +56,16 @@ function App() { api.checkConsent().then(ok => { setHasConsent(ok); setConsentChecked(true); - }).catch(() => setConsentChecked(true)); + }).catch(() => setConsentChecked(true)); // fail-open: if backend errors, allow app access }, []); const handleAcceptConsent = async () => { - await api.acceptConsent(); - setHasConsent(true); + try { + await api.acceptConsent(); + setHasConsent(true); + } catch (e) { + console.error("Failed to record consent:", e); + } }; if (consentChecked && !hasConsent) { From f757680fb89cfc9d8110719576f3a0c4c87b246d Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 15:31:05 +0000 Subject: [PATCH 12/19] feat: add Gallery page with grid/list view and free tier limit Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/App.tsx | 3 +- apps/desktop/src/components/GalleryItem.tsx | 59 +++++++++++++ .../desktop/src/components/GalleryToolbar.tsx | 42 ++++++++++ apps/desktop/src/pages/GalleryPage.tsx | 84 +++++++++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/GalleryItem.tsx create mode 100644 apps/desktop/src/components/GalleryToolbar.tsx create mode 100644 apps/desktop/src/pages/GalleryPage.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index eafce68..be3af18 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -12,6 +12,7 @@ import { ClipperPage } from "./pages/ClipperPage"; import { ArchivePage } from "./pages/ArchivePage"; import { SearchPage } from "./pages/SearchPage"; import { AIPage } from "./pages/AIPage"; +import { GalleryPage } from "./pages/GalleryPage"; import { LegalConsent } from "./components/LegalConsent"; import { api } from "./lib/tauri"; @@ -132,7 +133,7 @@ function App() { {/* Main Content */}
{page === "yoinks" && } - {page === "gallery" &&
Gallery coming soon
} + {page === "gallery" && setPage(p as Page)} />} {page === "video" && } {page === "audio" && } {page === "images" && } diff --git a/apps/desktop/src/components/GalleryItem.tsx b/apps/desktop/src/components/GalleryItem.tsx new file mode 100644 index 0000000..2f0c0aa --- /dev/null +++ b/apps/desktop/src/components/GalleryItem.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { FileText, Download, Archive, Image, Film, Music, Star, Pin, ExternalLink } from "lucide-react"; +import type { GalleryItem as GalleryItemType } from "../lib/tauri"; + +const TYPE_ICONS: Record> = { + download: Download, + article: FileText, + page: FileText, + archive: Archive, + image: Image, + video: Film, + audio: Music, +}; + +const FLAG_ICONS: Record> = { + star: Star, + pin: Pin, +}; + +interface GalleryItemProps { + item: GalleryItemType; + view: "grid" | "list"; +} + +export function GalleryItemCard({ item, view }: GalleryItemProps) { + const Icon = TYPE_ICONS[item.source_type] || Download; + const FlagIcon = FLAG_ICONS[item.flag]; + const date = new Date(item.created_at).toLocaleDateString(); + + if (view === "list") { + return ( +
+
+ +
+
+

{item.title}

+

{item.url}

+
+ {FlagIcon && } + {date} + +
+ ); + } + + return ( +
+
+ +
+

{item.title}

+
+ {date} + {FlagIcon && } +
+
+ ); +} diff --git a/apps/desktop/src/components/GalleryToolbar.tsx b/apps/desktop/src/components/GalleryToolbar.tsx new file mode 100644 index 0000000..9fd4e11 --- /dev/null +++ b/apps/desktop/src/components/GalleryToolbar.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { LayoutGrid, List } from "lucide-react"; +import { usePro } from "../hooks/usePro"; +import { ProBadge } from "./ProBadge"; + +interface GalleryToolbarProps { + view: "grid" | "list"; + onViewChange: (v: "grid" | "list") => void; + count: number; + limit: number; + onNavigatePro: () => void; +} + +export function GalleryToolbar({ view, onViewChange, count, limit, onNavigatePro }: GalleryToolbarProps) { + const { isPro } = usePro(); + + return ( +
+
+

Gallery

+ {!isPro && count > 30 && ( + {count}/{limit} + )} +
+
+ {!isPro && ( + + )} +
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/pages/GalleryPage.tsx b/apps/desktop/src/pages/GalleryPage.tsx new file mode 100644 index 0000000..9897db9 --- /dev/null +++ b/apps/desktop/src/pages/GalleryPage.tsx @@ -0,0 +1,84 @@ +import React, { useState } from "react"; +import { LayoutGrid } from "lucide-react"; +import { useGallery } from "../hooks/useGallery"; +import { usePro } from "../hooks/usePro"; +import { useSettings } from "../hooks/useSettings"; +import { GalleryItemCard } from "../components/GalleryItem"; +import { GalleryToolbar } from "../components/GalleryToolbar"; +import { api } from "../lib/tauri"; + +const FREE_LIMIT = 50; + +interface GalleryPageProps { + onNavigate?: (page: string) => void; +} + +export function GalleryPage({ onNavigate }: GalleryPageProps) { + const { items, count, loading, loadMore } = useGallery(); + const { isPro } = usePro(); + const { settings, updateSettings } = useSettings(); + const [view, setView] = useState<"grid" | "list">((settings?.gallery_view as "grid" | "list") || "grid"); + + const handleViewChange = (v: "grid" | "list") => { + setView(v); + if (settings) { + updateSettings({ ...settings, gallery_view: v }); + } + }; + + const displayItems = isPro ? items : items.slice(0, FREE_LIMIT); + const isFull = !isPro && count >= FREE_LIMIT; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ onNavigate?.("pro")} + /> + + {isFull && ( +
+ Gallery full · {count}/{FREE_LIMIT} items · Upgrade for unlimited + collections + +
+ )} + + {displayItems.length === 0 ? ( +
+ +

No yoinks yet

+

Download, clip, or archive something to see it here

+
+ ) : ( + <> +
+ {displayItems.map((item) => ( + + ))} +
+ {isPro && items.length < count && ( + + )} + + )} +
+ ); +} From 3f6922e0c6dd6a43739ecebe294bc19f26179f8f Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 17:21:39 +0000 Subject: [PATCH 13/19] fix: remove dead api import, fix hardcoded count threshold in gallery Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/components/GalleryToolbar.tsx | 2 +- apps/desktop/src/pages/GalleryPage.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/components/GalleryToolbar.tsx b/apps/desktop/src/components/GalleryToolbar.tsx index 9fd4e11..980953d 100644 --- a/apps/desktop/src/components/GalleryToolbar.tsx +++ b/apps/desktop/src/components/GalleryToolbar.tsx @@ -18,7 +18,7 @@ export function GalleryToolbar({ view, onViewChange, count, limit, onNavigatePro

Gallery

- {!isPro && count > 30 && ( + {!isPro && count > Math.floor(limit * 0.6) && ( {count}/{limit} )}
diff --git a/apps/desktop/src/pages/GalleryPage.tsx b/apps/desktop/src/pages/GalleryPage.tsx index 9897db9..2424acd 100644 --- a/apps/desktop/src/pages/GalleryPage.tsx +++ b/apps/desktop/src/pages/GalleryPage.tsx @@ -5,7 +5,6 @@ import { usePro } from "../hooks/usePro"; import { useSettings } from "../hooks/useSettings"; import { GalleryItemCard } from "../components/GalleryItem"; import { GalleryToolbar } from "../components/GalleryToolbar"; -import { api } from "../lib/tauri"; const FREE_LIMIT = 50; From a6b45ef8301692e17c1388db7381ff7d3db02d39 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 18:49:08 +0000 Subject: [PATCH 14/19] feat: add Pro gating to video quality and audio format/quality selectors Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/pages/AudioPage.tsx | 21 +++++++++++++++------ apps/desktop/src/pages/VideoPage.tsx | 21 ++++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/pages/AudioPage.tsx b/apps/desktop/src/pages/AudioPage.tsx index 6bc11a3..7862d95 100644 --- a/apps/desktop/src/pages/AudioPage.tsx +++ b/apps/desktop/src/pages/AudioPage.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; import { useDownloads } from "../hooks/useDownloads"; +import { usePro } from "../hooks/usePro"; import { DownloadList } from "../components/DownloadList"; +import { ProBadge } from "../components/ProBadge"; import { Button, UrlField } from "@yoinkit/ui"; import { FileText } from "lucide-react"; import { api } from "../lib/tauri"; export function AudioPage() { const { downloads, startVideoDownload, pauseDownload, resumeDownload, cancelDownload, deleteDownload } = useDownloads(); + const { isPro } = usePro(); const [url, setUrl] = useState(""); const [loading, setLoading] = useState(false); const [format, setFormat] = useState("mp3"); @@ -79,18 +82,24 @@ export function AudioPage() {
Format
- {formats.map(f => ( - - ))} + {formats.map(f => { + const locked = !isPro && f !== "mp3"; + return ( + + ); + })}
Quality
- {qualities.map(q => ( - - ))} + {qualities.map(q => { + const locked = !isPro && q.value === "0"; + return ( + + ); + })}
diff --git a/apps/desktop/src/pages/VideoPage.tsx b/apps/desktop/src/pages/VideoPage.tsx index 42f8fc6..06b287e 100644 --- a/apps/desktop/src/pages/VideoPage.tsx +++ b/apps/desktop/src/pages/VideoPage.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { useDownloads } from "../hooks/useDownloads"; +import { usePro } from "../hooks/usePro"; import { DownloadList } from "../components/DownloadList"; +import { ProBadge } from "../components/ProBadge"; import { Button, UrlField } from "@yoinkit/ui"; import { Info, Subtitles, FileText } from "lucide-react"; import { api } from "../lib/tauri"; @@ -25,6 +27,7 @@ interface FormatInfo { export function VideoPage() { const { downloads, startVideoDownload, pauseDownload, resumeDownload, cancelDownload, deleteDownload } = useDownloads(); + const { isPro } = usePro(); const [url, setUrl] = useState(""); const [videoInfo, setVideoInfo] = useState(null); const [loading, setLoading] = useState(false); @@ -131,15 +134,23 @@ export function VideoPage() {
+

+ + Ensure you have permission to download this content. +

+ {/* Quality — Apple segmented control */}
Quality
- {qualities.map(q => ( - - ))} + {qualities.map(q => { + const locked = !isPro && (q === "4k" || q === "1080p"); + return ( + + ); + })}
From 31366babe58aa0935aec5399cfe5cc7082184be2 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 18:49:09 +0000 Subject: [PATCH 15/19] feat: add license key management and legal links to Settings Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/pages/SettingsPage.tsx | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/pages/SettingsPage.tsx b/apps/desktop/src/pages/SettingsPage.tsx index 6f85383..f8cbbb7 100644 --- a/apps/desktop/src/pages/SettingsPage.tsx +++ b/apps/desktop/src/pages/SettingsPage.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useSettings } from "../hooks/useSettings"; import { Button } from "@yoinkit/ui"; -import { FolderOpen, MousePointer, Layers, Crown, Loader2, Sun, Moon, Monitor, NotebookPen, Brain, KeyRound, Link, Gauge } from "lucide-react"; +import { FolderOpen, MousePointer, Layers, Crown, Loader2, Sun, Moon, Monitor, NotebookPen, Brain, KeyRound, Link, Gauge, CheckCircle2 } from "lucide-react"; import { useThemeContext } from "../App"; const AI_PROVIDER_DEFAULTS: Record = { @@ -281,6 +281,33 @@ export function SettingsPage() {
+ + {/* Pro License */} +
+

Pro License

+ {settings.pro_unlocked ? ( +
+ + Pro Unlocked + {settings.pro_since && since {new Date(settings.pro_since).toLocaleDateString()}} +
+ ) : ( +
+ Free Plan +
+ )} +
+ + {/* Legal */} +
+

Legal

+
+ Terms of Use + Privacy Policy + DMCA Policy +

copyright@yoinkit.app

+
+
); From 076af93cb96fb6d583b2ff3937e8f8cfdd73c1ab Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 18:49:10 +0000 Subject: [PATCH 16/19] feat: add legal info touchpoints to Clipper and Archive pages Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/pages/ArchivePage.tsx | 7 ++++++- apps/desktop/src/pages/ClipperPage.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/pages/ArchivePage.tsx b/apps/desktop/src/pages/ArchivePage.tsx index 1639848..c23a37d 100644 --- a/apps/desktop/src/pages/ArchivePage.tsx +++ b/apps/desktop/src/pages/ArchivePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from "react"; -import { Trash2, ExternalLink, Archive as ArchiveIcon } from "lucide-react"; +import { Trash2, ExternalLink, Archive as ArchiveIcon, Info } from "lucide-react"; import { useClips } from "../hooks/useClips"; import { Button } from "@yoinkit/ui"; import { UrlField } from "@yoinkit/ui"; @@ -111,6 +111,11 @@ export function ArchivePage() { +

+ + Archives are for personal offline access. Fair dealing applies to research and private study. +

+ {/* Error */} {error && (

diff --git a/apps/desktop/src/pages/ClipperPage.tsx b/apps/desktop/src/pages/ClipperPage.tsx index 70186ca..00962ab 100644 --- a/apps/desktop/src/pages/ClipperPage.tsx +++ b/apps/desktop/src/pages/ClipperPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Copy, Trash2, Upload, FileText } from "lucide-react"; +import { Copy, Trash2, Upload, FileText, Info } from "lucide-react"; import { useClips } from "../hooks/useClips"; import { useSettings } from "../hooks/useSettings"; import { MarkdownPreview } from "../components/MarkdownPreview"; @@ -129,6 +129,11 @@ export function ClipperPage() { +

+ + Clips are saved locally for personal reference. Credit original creators when sharing. +

+ {/* Error */} {error && (

From 476e217803103f3cc11c1c4fa60d7f197da4630c Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 18:49:12 +0000 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20rewrite=20Pro=20page=20=E2=80=94?= =?UTF-8?q?=20shop=20window=20for=20free,=20dashboard=20for=20Pro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/pages/ProPage.tsx | 169 +++++++++++++++++------------ 1 file changed, 101 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/pages/ProPage.tsx b/apps/desktop/src/pages/ProPage.tsx index ec51796..a765db2 100644 --- a/apps/desktop/src/pages/ProPage.tsx +++ b/apps/desktop/src/pages/ProPage.tsx @@ -1,91 +1,124 @@ -import { useState } from "react"; -import { useDownloads } from "../hooks/useDownloads"; +import React, { useState } from "react"; +import { Crown, Zap, Video, Music, LayoutGrid, Layers, Gauge, Calendar, Bot, Terminal, Search, CheckCircle2 } from "lucide-react"; +import { usePro } from "../hooks/usePro"; import { useSettings } from "../hooks/useSettings"; -import { WgetFlags } from "../lib/tauri"; -import { UrlField, Button } from "@yoinkit/ui"; -import { CommandBuilder } from "../components/CommandBuilder"; -import { CommandPreview } from "../components/CommandPreview"; -import { PresetManager } from "../components/PresetManager"; -import { BatchInput } from "../components/BatchInput"; -import { DownloadList } from "../components/DownloadList"; -import { Lock, Crown } from "lucide-react"; - -type ProTab = "single" | "batch"; +import { api } from "../lib/tauri"; +import { ConfettiCelebration } from "../components/ConfettiCelebration"; export function ProPage() { - const { downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, deleteDownload } = useDownloads(); + const { isPro, proSince } = usePro(); const { settings } = useSettings(); - const [url, setUrl] = useState(""); - const [flags, setFlags] = useState({}); - const [loading, setLoading] = useState(false); - const [tab, setTab] = useState("single"); + const [licenseKey, setLicenseKey] = useState(""); + const [activating, setActivating] = useState(false); + const [error, setError] = useState(""); + const [showConfetti, setShowConfetti] = useState(false); + + const handleActivate = async () => { + if (!licenseKey.trim()) return; + setActivating(true); + setError(""); + try { + const result = await api.activateLicense(licenseKey.trim()); + if (result.success) { + setShowConfetti(true); + } else { + setError(result.error || "Activation failed"); + } + } catch (e: any) { + setError(e.toString()); + } finally { + setActivating(false); + } + }; - if (!settings.pro_unlocked) { + if (isPro) { + // Pro dashboard return ( -

-
- -
-
-

Pro Mode

-

- Unlock the full power of Wget with the visual command builder, presets, batch downloads, and more. -

+
+
+
+ +
+
+

Pro

+ {proSince &&

Member since {new Date(proSince).toLocaleDateString()}

} +
- +

All Pro features are unlocked. Enjoy the full toolkit.

); } - const handleSingleDownload = async () => { - if (!url.trim()) return; - setLoading(true); - try { await startDownload(url.trim(), flags); setUrl(""); } - catch (err) { console.error("Download failed:", err); } - finally { setLoading(false); } - }; - - const handleBatchDownload = async (urls: string[]) => { - setLoading(true); - try { for (const u of urls) await startDownload(u, flags); } - catch (err) { console.error("Batch download failed:", err); } - finally { setLoading(false); } - }; + // Free state — shop window + const features = [ + { icon: Video, title: "4K & 1080p Video", desc: "Download in full quality, any format" }, + { icon: Music, title: "Lossless Audio", desc: "FLAC, WAV, AAC, Opus, 320kbps" }, + { icon: LayoutGrid, title: "Unlimited Gallery", desc: "Collections, tags, flags, smart folders" }, + { icon: Layers, title: "Batch Operations", desc: "Download, clip, and export in bulk" }, + { icon: Gauge, title: "Multi-Thread Downloads", desc: "Parallel chunked downloading" }, + { icon: Calendar, title: "Scheduling", desc: "Download scheduler & site monitoring" }, + { icon: Bot, title: "MCP Server", desc: "Claude Desktop integration" }, + { icon: Terminal, title: "Wget Builder", desc: "Visual command builder & presets" }, + { icon: Search, title: "Advanced Search", desc: "Regex, filters, saved searches" }, + ]; return (
-
-
-

Pro

-

Full Wget command builder

-
-
- - + {showConfetti && } + + {/* Hero */} +
+
+
+

Unlock the full toolkit

+

£19 one-time purchase · Yours forever

- {tab === "single" ? ( -
- - -
- ) : ( - - )} + {/* Feature grid */} +
+ {features.map(({ icon: Icon, title, desc }) => ( +
+ +

{title}

+

{desc}

+
+ ))} +
-
-
- - -
-
+ {/* CTA */} +
+ + Upgrade to Pro · £19 + +

One-time purchase. No subscription. Ever.

- + {/* License key input */} +
+

Already have a license key?

+
+ setLicenseKey(e.target.value)} + placeholder="Paste your license key" + className="flex-1 px-3 py-2 rounded-lg bg-[var(--bg)] border border-[var(--border)] text-sm" + /> + +
+ {error &&

{error}

} +
); } From b476aac2abbc09aae48078eee62fcaf219b9ee7d Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 18:49:48 +0000 Subject: [PATCH 18/19] fix: default video quality to 720p for free users Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/pages/VideoPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/pages/VideoPage.tsx b/apps/desktop/src/pages/VideoPage.tsx index 06b287e..ac0bb8c 100644 --- a/apps/desktop/src/pages/VideoPage.tsx +++ b/apps/desktop/src/pages/VideoPage.tsx @@ -32,7 +32,7 @@ export function VideoPage() { const [videoInfo, setVideoInfo] = useState(null); const [loading, setLoading] = useState(false); const [fetching, setFetching] = useState(false); - const [quality, setQuality] = useState("1080p"); + const [quality, setQuality] = useState(isPro ? "1080p" : "720p"); const [writeSubs, setWriteSubs] = useState(false); const [subLang, setSubLang] = useState("en"); const [subFormat, setSubFormat] = useState("srt"); From 48a681fd5448386194b1c862bb900e9df3bed675 Mon Sep 17 00:00:00 2001 From: Nick Waters Date: Sun, 22 Mar 2026 18:50:29 +0000 Subject: [PATCH 19/19] chore: bump version to v0.3.0 for Pro tier release Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 578f0cb..bbd2e13 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "Yoinkit", - "version": "0.1.0", + "version": "0.3.0", "identifier": "com.yoinkit.app", "build": { "frontendDist": "../dist",