From 529f81f01c381ff37cda46b29594d76667b39a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 02:49:27 +0000 Subject: [PATCH 1/2] docs: add CLAUDE.md with codebase overview for AI assistants Covers project structure, build commands, architecture, data flow, code conventions, and key features. https://claude.ai/code/session_015v7KbgsaJAaxhvvcvg76EV --- CLAUDE.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..674ff8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +## Project Overview + +**Zero-State** is a Chrome Extension (Manifest V3) that replaces the New Tab page with a customizable link and notes organizer. Users can create hierarchical lists of links and notes, organized as trees. Built with TypeScript and VanJS. + +- **Chrome Web Store**: [zero-state](https://chromewebstore.google.com/detail/zero-state/diloncejoolaamlldicgmhimamhecipj) +- **Repository**: https://github.com/nmai/zero-state + +## Build & Development Commands + +```bash +npm run build # Bundle with esbuild → dist/app.js (with sourcemaps) +npm run build-min # Bundle minified → dist/app.js +npm run watch # Watch mode for development +npm run pkg # Copy manifest, static assets, and dist into pkg/ for publishing +``` + +There is no test suite, linter, or CI/CD pipeline configured. + +## Architecture + +### Tech Stack + +- **TypeScript** 5.8 with strict mode, targeting ES2019 +- **VanJS** 1.5.3 — ultralight (~5kb) reactive UI framework +- **esbuild** — bundler +- **Chrome APIs** — `chrome.storage.sync`, `chrome.permissions`, `chrome.runtime` + +### Source Layout (`ts/`) + +| File | Role | +|------|------| +| `app.ts` | Entry point. Main UI rendering, event handlers, keyboard shortcuts | +| `app.state.ts` | Centralized reactive state (`AppState` class with static VanJS states) | +| `types.ts` | TypeScript interfaces (`LinkNodeFlat`, `LinkNode`, `Settings`, etc.) | +| `constants.ts` | Constants and SVG icon strings | +| `van.ts` | VanJS re-exports | +| `edit.component.ts` | Edit/add form component | +| `settings.component.ts` | Settings modal component | +| `footer.component.ts` | Footer with messaging/prompts | +| `storage.service.ts` | Chrome storage sync API wrapper | +| `tree.service.ts` | Builds tree structure from flat node array | +| `favicon.service.ts` | Favicon provider management and caching | +| `theme.service.ts` | Theme application (light/dark/system) | +| `validator.service.ts` | Input validation | + +### Static Assets (`static/`) + +- `index.html` — single-page entry point, loads `dist/app.js` +- `css/custom.css` — main styles with CSS custom properties for theming +- `css/tree-list.css` — tree structure styling +- `icons/` — extension icons at various sizes +- `json/initial-data-2.0.0.json` — default data for new installs + +### Data Flow + +``` +Chrome Storage ↔ StorageService ↔ AppState (reactive) ↔ Components ↔ DOM +``` + +- Data is stored as a **flat array** of `LinkNodeFlat` objects with parent references +- `TreeService.buildTree()` converts flat data into a nested `LinkNode` tree for rendering +- `AppState` holds reactive VanJS `state()` values; components derive from these +- Changes flow back through `StorageService` which writes to `chrome.storage.sync` + +### Chrome Storage Constraints + +See `notes.md` for details. Key limits: +- 512 items max +- 100kb total across all keys +- 8kb per key +- All node data is stored under a single key (`links-v1`) + +## Code Conventions + +### Naming + +- **PascalCase** for classes: `StorageService`, `EditForm`, `TreeService` +- **camelCase** for methods and variables +- **UPPER_SNAKE_CASE** for constants: `CURRENT_LIST_VERSION`, `DOM_CLASSES` + +### File Naming + +- `.service.ts` suffix for service modules (data/logic layer) +- `.component.ts` suffix for UI components +- `.state.ts` suffix for state management + +### Patterns + +- Service classes use **static methods** (no instantiation) +- Components encapsulate UI logic and return VanJS DOM elements +- Async/Promise-based Chrome API interactions +- Immutable state updates (spread operator for arrays/objects) +- `nameToIndexMap` cache for O(1) lookups by item name +- Error handling: try-catch with console logging, user alerts for critical errors, revert-on-failure for optimistic updates + +### CSS + +- CSS custom properties for theming (`--bg-color`, etc.) +- `light-dark()` CSS function with `prefers-color-scheme` media query +- Lowercase hyphenated class names (`tree-list`, `edit-node-btn`) + +## Key Features in Code + +- **Tree visualization**: hierarchical lists with collapsible parent-child nodes +- **Edit mode**: toggled via `[+]` button or backtick key +- **Task completion**: right-click to strikethrough (optional setting) +- **Favicon support**: multiple providers (Chrome cache, DuckDuckGo, generic icon) +- **Theme support**: light, dark, or system preference +- **Keyboard shortcuts**: Escape (close/exit), backtick (toggle edit mode) +- **Cross-device sync**: via Chrome storage sync API when user is signed in From 6b3001cc26a7e4b451ef310c14667cd0a95e2694 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 03:10:37 +0000 Subject: [PATCH 2/2] refactor: restructure codebase into layered architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major reorganization of the flat ts/ directory into a layered structure: - core/ (types, constants, van.ts) - state/ (UIState + DataState, split from monolithic AppState) - services/ (storage, tree, favicon, theme, validator, new CommandService) - components/ (tree, edit-form, settings, footer) Key improvements: - CommandService decouples UI from persistence layer - Deduplicated save-and-rebuild-tree pattern (was repeated 5+ times) - Extracted form reset into reusable function - Removed dead code (createdTable state, commented-out code) - Moved SVG icons from inline strings in constants.ts to .svg files - Split monolithic custom.css (571 lines) into 8 focused CSS files - Fixed dark mode hover bug in tree list (hardcoded lightgray → var) Storage-persisted enum values are preserved to avoid breaking existing users. https://claude.ai/code/session_015v7KbgsaJAaxhvvcvg76EV --- CLAUDE.md | 101 ++-- package-lock.json | 4 +- static/css/base.css | 18 + static/css/buttons.css | 91 ++++ static/css/custom.css | 570 ---------------------- static/css/footer.css | 24 + static/css/forms.css | 88 ++++ static/css/layout.css | 23 + static/css/modal.css | 94 ++++ static/css/tree-list.css | 65 --- static/css/tree.css | 120 +++++ static/css/variables.css | 60 +++ static/icons/svg/close.svg | 4 + static/icons/svg/edit.svg | 3 + static/icons/svg/link.svg | 4 + static/icons/svg/settings.svg | 4 + static/index.html | 14 +- ts/app.ts | 381 +++------------ ts/components/edit-form.component.ts | 187 +++++++ ts/components/footer.component.ts | 36 ++ ts/{ => components}/settings.component.ts | 79 ++- ts/components/tree.component.ts | 153 ++++++ ts/constants.ts | 52 -- ts/core/constants.ts | 47 ++ ts/{ => core}/types.ts | 5 +- ts/{ => core}/van.ts | 4 - ts/edit.component.ts | 242 --------- ts/footer.component.ts | 49 -- ts/services/command.service.ts | 127 +++++ ts/{ => services}/favicon.service.ts | 41 +- ts/{ => services}/storage.service.ts | 37 +- ts/{ => services}/theme.service.ts | 13 +- ts/{ => services}/tree.service.ts | 37 +- ts/services/validator.service.ts | 5 + ts/{app.state.ts => state/data.state.ts} | 65 +-- ts/state/ui.state.ts | 22 + ts/validator.service.ts | 27 - 37 files changed, 1332 insertions(+), 1564 deletions(-) create mode 100644 static/css/base.css create mode 100644 static/css/buttons.css delete mode 100644 static/css/custom.css create mode 100644 static/css/footer.css create mode 100644 static/css/forms.css create mode 100644 static/css/layout.css create mode 100644 static/css/modal.css delete mode 100644 static/css/tree-list.css create mode 100644 static/css/tree.css create mode 100644 static/css/variables.css create mode 100644 static/icons/svg/close.svg create mode 100644 static/icons/svg/edit.svg create mode 100644 static/icons/svg/link.svg create mode 100644 static/icons/svg/settings.svg create mode 100644 ts/components/edit-form.component.ts create mode 100644 ts/components/footer.component.ts rename ts/{ => components}/settings.component.ts (57%) create mode 100644 ts/components/tree.component.ts delete mode 100644 ts/constants.ts create mode 100644 ts/core/constants.ts rename ts/{ => core}/types.ts (80%) rename ts/{ => core}/van.ts (66%) delete mode 100644 ts/edit.component.ts delete mode 100644 ts/footer.component.ts create mode 100644 ts/services/command.service.ts rename ts/{ => services}/favicon.service.ts (60%) rename ts/{ => services}/storage.service.ts (72%) rename ts/{ => services}/theme.service.ts (56%) rename ts/{ => services}/tree.service.ts (61%) create mode 100644 ts/services/validator.service.ts rename ts/{app.state.ts => state/data.state.ts} (59%) create mode 100644 ts/state/ui.state.ts delete mode 100644 ts/validator.service.ts diff --git a/CLAUDE.md b/CLAUDE.md index 674ff8f..e680c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,42 +27,64 @@ There is no test suite, linter, or CI/CD pipeline configured. - **esbuild** — bundler - **Chrome APIs** — `chrome.storage.sync`, `chrome.permissions`, `chrome.runtime` -### Source Layout (`ts/`) - -| File | Role | -|------|------| -| `app.ts` | Entry point. Main UI rendering, event handlers, keyboard shortcuts | -| `app.state.ts` | Centralized reactive state (`AppState` class with static VanJS states) | -| `types.ts` | TypeScript interfaces (`LinkNodeFlat`, `LinkNode`, `Settings`, etc.) | -| `constants.ts` | Constants and SVG icon strings | -| `van.ts` | VanJS re-exports | -| `edit.component.ts` | Edit/add form component | -| `settings.component.ts` | Settings modal component | -| `footer.component.ts` | Footer with messaging/prompts | -| `storage.service.ts` | Chrome storage sync API wrapper | -| `tree.service.ts` | Builds tree structure from flat node array | -| `favicon.service.ts` | Favicon provider management and caching | -| `theme.service.ts` | Theme application (light/dark/system) | -| `validator.service.ts` | Input validation | - -### Static Assets (`static/`) - -- `index.html` — single-page entry point, loads `dist/app.js` -- `css/custom.css` — main styles with CSS custom properties for theming -- `css/tree-list.css` — tree structure styling -- `icons/` — extension icons at various sizes -- `json/initial-data-2.0.0.json` — default data for new installs +### Source Layout + +``` +ts/ +├── app.ts # Entry point: initialization, keyboard shortcuts, top-level render +├── core/ +│ ├── types.ts # Interfaces (LinkNodeFlat, LinkNode, Settings) and FaviconProvider enum +│ ├── constants.ts # Storage keys, DOM classes, text icons, SVG loader, default settings +│ └── van.ts # VanJS re-exports (dependency isolation) +├── state/ +│ ├── ui.state.ts # UI-only state (editMode, settingsMode, editingNode, footerMessages) +│ └── data.state.ts # Persistent data state (rawList, names, root tree, settings) +├── services/ +│ ├── command.service.ts # Orchestrates state mutations + persistence (the main action layer) +│ ├── storage.service.ts # Chrome storage sync API wrapper (load/save) +│ ├── tree.service.ts # Builds tree structure from flat node array +│ ├── favicon.service.ts # Favicon provider management and caching +│ ├── theme.service.ts # Theme application (light/dark/system) +│ └── validator.service.ts # URL validation +└── components/ + ├── tree.component.ts # Tree rendering, node content, move/delete buttons + ├── edit-form.component.ts # Add/edit form with validation + ├── settings.component.ts # Settings modal + └── footer.component.ts # Footer with messaging/prompts +``` + +### Static Assets + +``` +static/ +├── index.html # Single-page entry point +├── css/ +│ ├── variables.css # Theme CSS custom properties (light/dark) +│ ├── base.css # Body, links, utility classes +│ ├── layout.css # Flexbox layout (row, col, main-content) +│ ├── buttons.css # Toggle, move, edit, close buttons +│ ├── tree.css # Tree lines, node styles, favicons, editable highlights +│ ├── modal.css # Settings modal and overlay +│ ├── forms.css # Edit form inputs, selects, actions +│ └── footer.css # Fixed footer bar +├── icons/ +│ ├── svg/ # SVG icon files (edit, close, settings, link) +│ └── *.png # Extension icons at various sizes +└── json/ + └── initial-data-2.0.0.json # Default data for new installs +``` ### Data Flow ``` -Chrome Storage ↔ StorageService ↔ AppState (reactive) ↔ Components ↔ DOM +Chrome Storage ↔ StorageService ↔ CommandService ↔ DataState/UIState ↔ Components ↔ DOM ``` - Data is stored as a **flat array** of `LinkNodeFlat` objects with parent references - `TreeService.buildTree()` converts flat data into a nested `LinkNode` tree for rendering -- `AppState` holds reactive VanJS `state()` values; components derive from these -- Changes flow back through `StorageService` which writes to `chrome.storage.sync` +- `CommandService` is the single orchestration layer — components call it instead of directly touching storage or rebuilding the tree +- `UIState` holds transient UI state (edit mode, modals); `DataState` holds persistent data (list, settings) +- Changes sync across devices via `chrome.storage.sync` ### Chrome Storage Constraints @@ -72,38 +94,41 @@ See `notes.md` for details. Key limits: - 8kb per key - All node data is stored under a single key (`links-v1`) +**Important**: The `FaviconProvider` enum string values (`'chrome'`, `'duck'`, `'gen'`, `'none'`) are persisted in storage. Never change these values — it would break settings for existing users. + ## Code Conventions ### Naming -- **PascalCase** for classes: `StorageService`, `EditForm`, `TreeService` +- **PascalCase** for classes: `StorageService`, `EditForm`, `TreeComponent` - **camelCase** for methods and variables - **UPPER_SNAKE_CASE** for constants: `CURRENT_LIST_VERSION`, `DOM_CLASSES` ### File Naming -- `.service.ts` suffix for service modules (data/logic layer) -- `.component.ts` suffix for UI components -- `.state.ts` suffix for state management +- `.service.ts` — services (data/logic/orchestration layer) +- `.component.ts` — UI components +- `.state.ts` — state management ### Patterns - Service classes use **static methods** (no instantiation) -- Components encapsulate UI logic and return VanJS DOM elements -- Async/Promise-based Chrome API interactions +- Components call `CommandService` for any action that mutates state + persists (never call `StorageService` directly from a component) - Immutable state updates (spread operator for arrays/objects) -- `nameToIndexMap` cache for O(1) lookups by item name -- Error handling: try-catch with console logging, user alerts for critical errors, revert-on-failure for optimistic updates +- `nameToIndexMap` cache in `DataState` for O(1) lookups by item name +- Optimistic updates with revert-on-failure pattern in `CommandService` +- SVG icons are loaded from files at init time via `loadSvgIcons()`, stored in `SVG_ICONS` map ### CSS -- CSS custom properties for theming (`--bg-color`, etc.) +- CSS custom properties for theming (defined in `variables.css`) - `light-dark()` CSS function with `prefers-color-scheme` media query - Lowercase hyphenated class names (`tree-list`, `edit-node-btn`) +- One CSS file per concern (variables, layout, buttons, tree, modal, forms, footer) ## Key Features in Code -- **Tree visualization**: hierarchical lists with collapsible parent-child nodes +- **Tree visualization**: hierarchical lists with parent-child tree lines - **Edit mode**: toggled via `[+]` button or backtick key - **Task completion**: right-click to strikethrough (optional setting) - **Favicon support**: multiple providers (Chrome cache, DuckDuckGo, generic icon) diff --git a/package-lock.json b/package-lock.json index 275fa3e..814b61b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zero-state", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zero-state", - "version": "1.0.0", + "version": "2.0.0", "license": "ISC", "dependencies": { "typescript": "^5.8.2", diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..41a5e57 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,18 @@ +/* ===== BASE STYLES ===== */ +body { + color: var(--text-color); + background-color: var(--background-color); + font-size: 14px; + margin-left: 0; + margin-right: 0; +} + +a { + text-decoration: none; + color: var(--link-color); +} + +/* ===== UTILITY CLASSES ===== */ +.display-none { + display: none; +} diff --git a/static/css/buttons.css b/static/css/buttons.css new file mode 100644 index 0000000..7335fcc --- /dev/null +++ b/static/css/buttons.css @@ -0,0 +1,91 @@ +/* ===== TOGGLE & SETTINGS BUTTONS ===== */ +#toggle-form-btn { + float: right; +} + +#toggle-form-btn:hover, #settings-btn:hover { + color: var(--primary-button-hover-color); + transform: translateY(-1px); +} + +#toggle-form-btn:active, #settings-btn:active { + transform: translateY(0); +} + +/* ===== MOVE BUTTONS ===== */ +.move-controls { + display: inline-block; + margin-right: 5px; + vertical-align: middle; +} + +.move-btn { + display: block; + text-align: center; + width: 16px; + height: 16px; + line-height: 14px; + font-size: 12px; + margin: 1px 0; + border: 1px solid var(--border-color); + border-radius: 2px; + background-color: var(--secondary-bg-color); +} + +.move-btn:hover { + background-color: var(--hover-bg-color); +} + +.move-up { + margin-bottom: 0; +} + +.move-down { + margin-top: 0; +} + +/* ===== NODE EDIT BUTTON ===== */ +.edit-node-btn { + margin-left: 8px; + display: inline-flex; + align-items: center; + opacity: 0.7; + transition: opacity 0.2s, transform 0.1s; +} + +.edit-node-btn:hover { + opacity: 1; + transform: translateY(-1px); +} + +/* ===== CLOSE BUTTONS ===== */ +.close-settings-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + background-color: var(--secondary-bg-color); + transition: background-color 0.2s; +} + +.close-settings-btn:hover { + background-color: var(--hover-bg-color); +} + +.close-form-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--hover-bg-color); + transition: background-color 0.2s, transform 0.1s; +} + +.close-form-btn:hover { + background-color: var(--border-color); + transform: translateY(-1px); +} diff --git a/static/css/custom.css b/static/css/custom.css deleted file mode 100644 index 922d01c..0000000 --- a/static/css/custom.css +++ /dev/null @@ -1,570 +0,0 @@ -/* ===== THEME VARIABLES ===== */ -:root { - color-scheme: light dark; - - /* Base colors */ - --background-color: white; - --text-color: #24292e; - --link-color: #0366d6; - - /* UI elements */ - --primary-button-color: #4285f4; - --primary-button-hover-color: #1a73e8; - --border-color: #d1d5da; - --secondary-bg-color: #f6f8fa; - --hover-bg-color: #e1e4e8; - --footer-link-color: rgba(0,0,0,.5); -} - -/* Dark mode variables */ -@media (prefers-color-scheme: dark) { - :root { - /* Base colors */ - --background-color: #101010; - /* --background-color: #000000; */ - --text-color: #bdc1c6; - --link-color: #4D99FA; - - /* UI elements */ - --primary-button-color: #8ab4f8; - --primary-button-hover-color: #aecbfa; - --border-color: #313438; - --secondary-bg-color: #202124; - --hover-bg-color: #303134; - --footer-link-color: rgba(255,255,255,.5); - } -} - -/* Explicit theme classes */ -.theme-light { - /* Base colors */ - --background-color: white; - --text-color: #24292e; - --link-color: #0366d6; - - /* UI elements */ - --primary-button-color: #4285f4; - --primary-button-hover-color: #1a73e8; - --border-color: #d1d5da; - --secondary-bg-color: #f6f8fa; - --hover-bg-color: #e1e4e8; - --footer-link-color: rgba(0,0,0,.5); -} - -.theme-dark { - /* Base colors */ - --background-color: #101010; - --text-color: #bdc1c6; - --link-color: #4D99FA; - - /* UI elements */ - --primary-button-color: #8ab4f8; - --primary-button-hover-color: #aecbfa; - --border-color: #313438; - --secondary-bg-color: #202124; - --hover-bg-color: #303134; - --footer-link-color: rgba(255,255,255,.5); -} - -/* ===== BASE STYLES ===== */ -body { - color: var(--text-color); - background-color: var(--background-color); - font-size: 14px; - margin-left: 0; - margin-right: 0; -} - -a { - text-decoration: none; - color: var(--link-color); -} - -/* ===== LAYOUT COMPONENTS ===== */ -.container { - margin: 0 auto; - max-width: 800px; -} - -.row { - display: flex; - min-height: calc(100vh - 80px); /* Account for footer height + some margin */ -} - -.main-content { - flex-grow: 1; - display: flex; - align-items: flex-start; -} - -.row-side-panel { - flex-grow: 0; -} - -.col, .col-md-3, .col-md-9 { - float: left; - position: relative; - min-height: 1px; - padding: 0 15px; -} - -.col { - max-width: 400px; -} - -.col-md-3 { - width: 25%; -} - -.col-md-9 { - width: 75%; -} - -/* ===== BUTTON COMPONENTS ===== */ -.button-group { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 16px; -} - -/* #toggle-form-btn, #settings-btn { - display: flex; - justify-content: center; - align-items: center; - width: 28px; - height: 28px; - background-color: transparent; - border-radius: 4px; - text-decoration: none; - color: var(--primary-button-color); - transition: color 0.2s, transform 0.1s; -} */ - -#toggle-form-btn { - float: right; -} - -#toggle-form-btn:hover, #settings-btn:hover { - color: var(--primary-button-hover-color); - transform: translateY(-1px); -} - -#toggle-form-btn:active, #settings-btn:active { - transform: translateY(0); -} - -/* Move buttons */ -.move-controls { - display: inline-block; - margin-right: 5px; - vertical-align: middle; -} - -.move-btn { - display: block; - text-align: center; - width: 16px; - height: 16px; - line-height: 14px; - font-size: 12px; - margin: 1px 0; - border: 1px solid var(--border-color); - border-radius: 2px; - background-color: var(--secondary-bg-color); -} - -.move-btn:hover { - background-color: var(--hover-bg-color); -} - -.move-up { - margin-bottom: 0; -} - -.move-down { - margin-top: 0; -} - -/* ===== TEXT STYLING ===== */ -.text-parent { - font-size: 16px; - font-weight: 600; -} - -.text-child { - font-weight: normal; - font-size: 14px; -} - -.text-linethrough { - text-decoration: line-through; -} - -/* ===== COMPONENT SPECIFIC STYLES ===== */ -/* Favicon */ -.favicon { - max-height: 18px; - float: left; - padding: 5px; - padding-right: 6px; -} - -.border-effect { - filter: - drop-shadow(1px 0 0 white) - drop-shadow(0 1px 0 white) - drop-shadow(-1px 0 0 white) - drop-shadow(0 -1px 0 white); -} - -/* -.old-favicon-container { - background-color: light-dark(white, #bdc1c6); - border: 1px solid #9aa0a6; - border-radius: 50%; - display: inline-flex; - justify-content: center; - align-items: center; - height: 1.8em; - width: 1.8em; - margin-right: 0.3em; - flex-shrink: 0; - vertical-align: middle; -} -*/ - -#overlay-container { - position: fixed; - width: 100%; - z-index: 1000; -} - -/* ===== ANIMATIONS ===== */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -/* Settings modal */ -.modal { - display: flex; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - justify-content: center; - align-items: center; -} - -/* Settings page */ -.settings-page { - position: relative; - width: 100%; - max-width: 500px; - margin: 20px; - padding: 20px; - background-color: var(--background-color); - border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - /* animation: fadeIn 0.2s ease-out; */ -} - -.settings-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color); -} - -.settings-group { - margin-bottom: 24px; -} - -.settings-group h3 { - margin-bottom: 12px; -} - -.setting-item { - margin-left: 10px; -} - -.setting-description { - font-size: 0.9em; - color: var(--footer-link-color); - margin-top: 5px; - margin-bottom: 15px; -} - -.select-wrapper { - position: relative; - width: 100%; - margin-bottom: 10px; -} - -.select-wrapper select { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: 4px; - background-color: var(--background-color); - color: var(--text-color); - box-sizing: border-box; - appearance: none; - cursor: pointer; -} - -.select-wrapper::after { - /* content: "▼"; */ - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - pointer-events: none; - font-size: 12px; - color: var(--text-color); -} - -.radio-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -.close-settings-btn { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: 50%; - background-color: var(--secondary-bg-color); - transition: background-color 0.2s; -} - -.close-settings-btn:hover { - background-color: var(--hover-bg-color); -} - -/* Footer */ -.footer { - position: fixed; - bottom: 0; - left: 0; - width: 100%; - display: flex; - /* justify-content: flex-end; */ - justify-content: space-between; - align-items: center; - height: 30px; - font-size: 12px; - /* border-top: 1px solid var(--border-color); */ - z-index: 100; -} - -.footer a { - color: var(--footer-link-color); - transition: color 0.2s, transform 0.1s; - padding: 5px 5px; -} - -.footer a:hover { - color: var(--primary-button-color); - transform: translateY(-1px); -} - -/* ===== UTILITY CLASSES ===== */ -.display-none { - display: none; -} - -/* Form controls in settings */ -.settings-page input[type="checkbox"] { - margin-right: 8px; - cursor: pointer; - width: 16px; - height: 16px; - vertical-align: middle; -} - -.settings-page label { - display: flex; - align-items: center; - cursor: pointer; - margin-bottom: 5px; -} - -.settings-page input[type="radio"] { - margin-right: 8px; - cursor: pointer; - width: 16px; - height: 16px; - vertical-align: middle; -} - -.settings-page h2 { - color: var(--text-color); - font-size: 1.5em; - font-weight: 500; - margin-top: 0; -} - -.settings-page h3 { - color: var(--text-color); - font-size: 1.2em; - font-weight: 500; -} - -/* Node edit button */ -.edit-node-btn { - margin-left: 8px; - display: inline-flex; - align-items: center; - opacity: 0.7; - transition: opacity 0.2s, transform 0.1s; -} - -.edit-node-btn:hover { - opacity: 1; - transform: translateY(-1px); -} - -/* Editable node styling */ -.editable-node { - position: relative; - padding: 2px 4px; - margin: -2px -4px; - border-radius: 3px; - transition: background-color 0.2s; - background-color: rgba(255, 235, 59, 0.25); /* Brighter yellow with opacity */ - border: 1px dashed rgba(255, 215, 0, 0.5); /* Gold border for better contrast */ -} - -.editable-node:hover { - background-color: rgba(255, 235, 59, 0.4); /* More intense yellow on hover */ - cursor: pointer; -} - -.editable-node:hover::after { - /* content: "✎"; */ - position: absolute; - right: -20px; - top: 50%; - transform: translateY(-50%); - font-size: 12px; - opacity: 0.7; - color: var(--primary-button-color); -} - -/* Button group behavior in edit mode */ -.row-side-panel .button-group { - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 16px; -} - -.form-header { - font-size: 16px; - font-weight: 500; - margin-bottom: 15px; - color: var(--text-color); - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 10px; - border-bottom: 1px solid var(--border-color); -} - -.close-form-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - border-radius: 50%; - background-color: var(--hover-bg-color); - transition: background-color 0.2s, transform 0.1s; -} - -.close-form-btn:hover { - background-color: var(--border-color); - transform: translateY(-1px); -} - -.form-actions { - display: flex; - gap: 10px; - margin-top: 10px; -} - -#newlink-form input[type="text"] { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--border-color); - border-radius: 4px; - margin-bottom: 10px; - background-color: var(--background-color); - color: var(--text-color); - box-sizing: border-box; -} - -#newlink-form input[type="submit"], -#newlink-form input[type="button"] { - padding: 6px 12px; - border: none; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s; -} - -#newlink-form input[type="submit"] { - background-color: transparent; - color: var(--primary-button-color); - border: 1px solid var(--primary-button-color); - font-weight: 500; -} - -#newlink-form input[type="submit"]:hover { - background-color: rgba(66, 133, 244, 0.1); - color: var(--primary-button-hover-color); -} - -#newlink-form input[type="button"] { - background-color: var(--hover-bg-color); - color: var(--text-color); -} - -#newlink-form input[type="button"]:hover { - background-color: var(--border-color); -} - -.form-hint { - margin-top: 15px; - font-size: 12px; - color: var(--footer-link-color); - font-style: italic; - /* text-align: center; */ - padding: 5px; - border-top: 1px solid var(--border-color); -} diff --git a/static/css/footer.css b/static/css/footer.css new file mode 100644 index 0000000..24510dd --- /dev/null +++ b/static/css/footer.css @@ -0,0 +1,24 @@ +/* ===== FOOTER ===== */ +.footer { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + height: 30px; + font-size: 12px; + z-index: 100; +} + +.footer a { + color: var(--footer-link-color); + transition: color 0.2s, transform 0.1s; + padding: 5px 5px; +} + +.footer a:hover { + color: var(--primary-button-color); + transform: translateY(-1px); +} diff --git a/static/css/forms.css b/static/css/forms.css new file mode 100644 index 0000000..8b8938a --- /dev/null +++ b/static/css/forms.css @@ -0,0 +1,88 @@ +/* ===== FORM LAYOUT ===== */ +.form-header { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + color: var(--text-color); + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.form-hint { + margin-top: 15px; + font-size: 12px; + color: var(--footer-link-color); + font-style: italic; + padding: 5px; + border-top: 1px solid var(--border-color); +} + +/* ===== FORM INPUTS ===== */ +#newlink-form input[type="text"] { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + margin-bottom: 10px; + background-color: var(--background-color); + color: var(--text-color); + box-sizing: border-box; +} + +#newlink-form input[type="submit"], +#newlink-form input[type="button"] { + padding: 6px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; +} + +#newlink-form input[type="submit"] { + background-color: transparent; + color: var(--primary-button-color); + border: 1px solid var(--primary-button-color); + font-weight: 500; +} + +#newlink-form input[type="submit"]:hover { + background-color: rgba(66, 133, 244, 0.1); + color: var(--primary-button-hover-color); +} + +#newlink-form input[type="button"] { + background-color: var(--hover-bg-color); + color: var(--text-color); +} + +#newlink-form input[type="button"]:hover { + background-color: var(--border-color); +} + +/* ===== SELECT DROPDOWNS ===== */ +.select-wrapper { + position: relative; + width: 100%; + margin-bottom: 10px; +} + +.select-wrapper select { + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--background-color); + color: var(--text-color); + box-sizing: border-box; + appearance: none; + cursor: pointer; +} diff --git a/static/css/layout.css b/static/css/layout.css new file mode 100644 index 0000000..9a364bc --- /dev/null +++ b/static/css/layout.css @@ -0,0 +1,23 @@ +/* ===== LAYOUT ===== */ +.row { + display: flex; + min-height: calc(100vh - 80px); +} + +.main-content { + flex-grow: 1; + display: flex; + align-items: flex-start; +} + +.row-side-panel { + flex-grow: 0; +} + +.col { + float: left; + position: relative; + min-height: 1px; + padding: 0 15px; + max-width: 400px; +} diff --git a/static/css/modal.css b/static/css/modal.css new file mode 100644 index 0000000..3b53097 --- /dev/null +++ b/static/css/modal.css @@ -0,0 +1,94 @@ +/* ===== MODAL ===== */ +.modal { + display: flex; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + justify-content: center; + align-items: center; +} + +#overlay-container { + position: fixed; + width: 100%; + z-index: 1000; +} + +/* ===== SETTINGS PAGE ===== */ +.settings-page { + position: relative; + width: 100%; + max-width: 500px; + margin: 20px; + padding: 20px; + background-color: var(--background-color); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.settings-group { + margin-bottom: 24px; +} + +.settings-group h3 { + margin-bottom: 12px; +} + +.setting-item { + margin-left: 10px; +} + +.setting-description { + font-size: 0.9em; + color: var(--footer-link-color); + margin-top: 5px; + margin-bottom: 15px; +} + +.radio-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-page input[type="checkbox"], +.settings-page input[type="radio"] { + margin-right: 8px; + cursor: pointer; + width: 16px; + height: 16px; + vertical-align: middle; +} + +.settings-page label { + display: flex; + align-items: center; + cursor: pointer; + margin-bottom: 5px; +} + +.settings-page h2 { + color: var(--text-color); + font-size: 1.5em; + font-weight: 500; + margin-top: 0; +} + +.settings-page h3 { + color: var(--text-color); + font-size: 1.2em; + font-weight: 500; +} diff --git a/static/css/tree-list.css b/static/css/tree-list.css deleted file mode 100644 index a4b319a..0000000 --- a/static/css/tree-list.css +++ /dev/null @@ -1,65 +0,0 @@ - -.tree-wrapper { - padding-top: 10px; -} -.tree-list { - list-style: none; - padding: 0; - margin: 0; -} -.tree-list .tree-item { - position: relative; - display: block; - min-height: 2em; - line-height: 2em; - margin-bottom: 10px; - padding-left: 21px; -} -.tree-list .tree-item:before, .tree-list .tree-item:after { - content: ''; - position: absolute; - display: block; - background-color: light-dark(#d1d5da,#313438); -} -.tree-list .tree-item:before { - top: 0; - left: 10px; - width: 1px; - height: calc(100% + 10px); -} -.tree-list .tree-item:after { - top: 1em; - left: 10px; - width: 11px; - height: 1px; -} -.tree-list .tree-item:last-child:not(:first-child) { - margin-bottom: 0; -} -.tree-list .tree-item:last-child:before { - height: 1em; -} -.tree-list .tree-item:first-child:before { - top: -10px; - /* height: calc(50% + 10px); */ - height: 25px; -} -.tree-list .tree-item:first-child:not(:last-child):before { - top: -10px; - height: calc(100% + 20px); -} -.tree-list .tree-item:last-child:not(:first-child):before { - height: 1em; -} -.tree-list .tree-item > span { - display: inline-block; - padding: 0 5px; - border: 1px solid light-dark(#d1d5da,#313438); -} -.tree-list .tree-item > span:hover { - background-color: lightgray; -} -.tree-list .tree-item > .tree-list { - padding-top: 10px; -} - diff --git a/static/css/tree.css b/static/css/tree.css new file mode 100644 index 0000000..bfb825c --- /dev/null +++ b/static/css/tree.css @@ -0,0 +1,120 @@ +/* ===== TREE LIST ===== */ +.tree-list { + list-style: none; + padding: 0; + margin: 0; +} + +.tree-list .tree-item { + position: relative; + display: block; + min-height: 2em; + line-height: 2em; + margin-bottom: 10px; + padding-left: 21px; +} + +.tree-list .tree-item:before, +.tree-list .tree-item:after { + content: ''; + position: absolute; + display: block; + background-color: light-dark(#d1d5da, #313438); +} + +.tree-list .tree-item:before { + top: 0; + left: 10px; + width: 1px; + height: calc(100% + 10px); +} + +.tree-list .tree-item:after { + top: 1em; + left: 10px; + width: 11px; + height: 1px; +} + +.tree-list .tree-item:last-child:not(:first-child) { + margin-bottom: 0; +} + +.tree-list .tree-item:last-child:before { + height: 1em; +} + +.tree-list .tree-item:first-child:before { + top: -10px; + height: 25px; +} + +.tree-list .tree-item:first-child:not(:last-child):before { + top: -10px; + height: calc(100% + 20px); +} + +.tree-list .tree-item:last-child:not(:first-child):before { + height: 1em; +} + +.tree-list .tree-item > span { + display: inline-block; + padding: 0 5px; + border: 1px solid light-dark(#d1d5da, #313438); +} + +.tree-list .tree-item > span:hover { + background-color: var(--hover-bg-color); +} + +.tree-list .tree-item > .tree-list { + padding-top: 10px; +} + +/* ===== TEXT STYLING ===== */ +.text-parent { + font-size: 16px; + font-weight: 600; +} + +.text-child { + font-weight: normal; + font-size: 14px; +} + +.text-linethrough { + text-decoration: line-through; +} + +/* ===== EDITABLE NODE ===== */ +.editable-node { + position: relative; + padding: 2px 4px; + margin: -2px -4px; + border-radius: 3px; + transition: background-color 0.2s; + background-color: rgba(255, 235, 59, 0.25); + border: 1px dashed rgba(255, 215, 0, 0.5); +} + +.editable-node:hover { + background-color: rgba(255, 235, 59, 0.4); + cursor: pointer; +} + +/* ===== FAVICON ===== */ +.favicon { + max-height: 18px; + float: left; + padding: 5px; + padding-right: 6px; +} + +.border-effect { + filter: + drop-shadow(1px 0 0 white) + drop-shadow(0 1px 0 white) + drop-shadow(-1px 0 0 white) + drop-shadow(0 -1px 0 white); +} diff --git a/static/css/variables.css b/static/css/variables.css new file mode 100644 index 0000000..5103c8f --- /dev/null +++ b/static/css/variables.css @@ -0,0 +1,60 @@ +/* ===== THEME VARIABLES ===== */ +:root { + color-scheme: light dark; + + /* Base colors */ + --background-color: white; + --text-color: #24292e; + --link-color: #0366d6; + + /* UI elements */ + --primary-button-color: #4285f4; + --primary-button-hover-color: #1a73e8; + --border-color: #d1d5da; + --secondary-bg-color: #f6f8fa; + --hover-bg-color: #e1e4e8; + --footer-link-color: rgba(0,0,0,.5); +} + +/* Dark mode variables */ +@media (prefers-color-scheme: dark) { + :root { + --background-color: #101010; + --text-color: #bdc1c6; + --link-color: #4D99FA; + + --primary-button-color: #8ab4f8; + --primary-button-hover-color: #aecbfa; + --border-color: #313438; + --secondary-bg-color: #202124; + --hover-bg-color: #303134; + --footer-link-color: rgba(255,255,255,.5); + } +} + +/* Explicit theme classes */ +.theme-light { + --background-color: white; + --text-color: #24292e; + --link-color: #0366d6; + + --primary-button-color: #4285f4; + --primary-button-hover-color: #1a73e8; + --border-color: #d1d5da; + --secondary-bg-color: #f6f8fa; + --hover-bg-color: #e1e4e8; + --footer-link-color: rgba(0,0,0,.5); +} + +.theme-dark { + --background-color: #101010; + --text-color: #bdc1c6; + --link-color: #4D99FA; + + --primary-button-color: #8ab4f8; + --primary-button-hover-color: #aecbfa; + --border-color: #313438; + --secondary-bg-color: #202124; + --hover-bg-color: #303134; + --footer-link-color: rgba(255,255,255,.5); +} diff --git a/static/icons/svg/close.svg b/static/icons/svg/close.svg new file mode 100644 index 0000000..24a1feb --- /dev/null +++ b/static/icons/svg/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/icons/svg/edit.svg b/static/icons/svg/edit.svg new file mode 100644 index 0000000..756a1bb --- /dev/null +++ b/static/icons/svg/edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/icons/svg/link.svg b/static/icons/svg/link.svg new file mode 100644 index 0000000..427b7cf --- /dev/null +++ b/static/icons/svg/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/icons/svg/settings.svg b/static/icons/svg/settings.svg new file mode 100644 index 0000000..9c67616 --- /dev/null +++ b/static/icons/svg/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/index.html b/static/index.html index ed022f1..a49b2ca 100644 --- a/static/index.html +++ b/static/index.html @@ -2,12 +2,16 @@ New Tab - - - - + + + + + + + + - \ No newline at end of file + diff --git a/ts/app.ts b/ts/app.ts index 9be592c..8e82717 100644 --- a/ts/app.ts +++ b/ts/app.ts @@ -1,357 +1,108 @@ -import { CURRENT_LIST_VERSION, DOM_CLASSES, ICONS, SETTINGS_VERSION, FAVICON_PROVIDER_NAMES } from './constants'; -import { SettingsComponent } from './settings.component'; -import { AppState } from './app.state'; -import { StorageService } from './storage.service'; -import { applyTheme } from './theme.service'; -import { LinkNode, LinkNodeFlat, Settings, FaviconProvider } from './types'; -import { add, state, derive, div, a, form, label, input, span, ul, li, br, img, h2, h3, p, select, option } from './van' -import { ValidatorService } from './validator.service'; -import { TreeService } from './tree.service'; -import { FaviconService } from './favicon.service'; -import { renderFooter } from './footer.component'; -import { EditForm } from './edit.component'; - - -// UI Components using VanJS -class UiComponents { - private static settingsComponent = new SettingsComponent(AppState); - - static createDeleteButton(node: LinkNodeFlat) { - return a({ href: "#", onclick: (e: Event) => { +import { CURRENT_LIST_VERSION, SETTINGS_VERSION, loadSvgIcons } from './core/constants'; +import { LinkNodeFlat, Settings } from './core/types'; +import { add, a, div } from './core/van'; +import { SettingsComponent } from './components/settings.component'; +import { EditForm } from './components/edit-form.component'; +import { TreeComponent } from './components/tree.component'; +import { renderFooter } from './components/footer.component'; +import { CommandService } from './services/command.service'; +import { StorageService } from './services/storage.service'; +import { UIState } from './state/ui.state'; + +const settingsComponent = new SettingsComponent(); + +function renderToggleButton() { + return a({ + id: "toggle-form-btn", + href: "#", + onclick: (e: Event) => { e.preventDefault(); - AppState.removeItem(node); - StorageService.save(AppState.rawList.val) - .then(() => { - AppState.root.val = TreeService.buildTree(AppState.rawList.val); - }) - .catch(error => { - console.error('Failed to save after delete:', error); - alert('Failed to delete item. Please try again.'); - }); - }}, ICONS.MINUS); - } - - static createMoveUpButton(node: LinkNode, siblings: LinkNode[], index: number) { - // Only show if not first child - if (index <= 0) return null; - - return a({ - href: "#", - class: "move-btn move-up", - onclick: (e: Event) => { - e.preventDefault(); - const prevSibling = siblings[index - 1]; - AppState.swapNodePositions(node, prevSibling); - StorageService.save(AppState.rawList.val) - .then(() => { - AppState.root.val = TreeService.buildTree(AppState.rawList.val); - }) - .catch(error => { - console.error('Failed to save after position change:', error); - AppState.swapNodePositions(node, prevSibling); // Revert on failure - alert('Failed to move item up. Please try again.'); - }); - } - }, ICONS.UP); - } + UIState.editMode.val = !UIState.editMode.val; + UIState.editingNode.val = null; - static createMoveDownButton(node: LinkNode, siblings: LinkNode[], index: number) { - // Only show if not last child - if (index >= siblings.length - 1) return null; - - return a({ - href: "#", - class: "move-btn move-down", - onclick: (e: Event) => { - e.preventDefault(); - const nextSibling = siblings[index + 1]; - AppState.swapNodePositions(node, nextSibling); - StorageService.save(AppState.rawList.val) - .then(() => { - AppState.root.val = TreeService.buildTree(AppState.rawList.val); - }) - .catch(error => { - console.error('Failed to save after position change:', error); - AppState.swapNodePositions(node, nextSibling); // Revert on failure - alert('Failed to move item down. Please try again.'); - }); + if (UIState.settingsMode.val) { + UIState.settingsMode.val = false; } - }, ICONS.DOWN); - } - - static createEditButton(node: LinkNodeFlat) { - return a({ - href: "#", - class: "edit-node-btn", - title: "Edit", - innerHTML: ICONS.EDIT, - onclick: (e: Event) => { - e.preventDefault(); - e.stopPropagation(); - - // Set the editing node in state - AppState.editingNode.val = node; - - // Make sure edit mode is enabled - if (!AppState.editMode.val) { - AppState.editMode.val = true; - } - - // The form will be populated based on this state in renderAddForm - } - }); - } - - static renderNodeContent(node: LinkNode) { - const contentClasses = []; - if (node.taskComplete) contentClasses.push(DOM_CLASSES.TEXT_LINETHROUGH); - - // Add editable class when in edit mode - if (AppState.editMode.val) contentClasses.push('editable-node'); - - const handleNodeClick = (e: Event) => { - // Only handle clicks when in edit mode - if (AppState.editMode.val) { - e.preventDefault(); - e.stopPropagation(); - - // Set the editing node in state - AppState.editingNode.val = node; - } - }; - - function faviconClasses(node: LinkNodeFlat) { - if (node.border == 1) - return "favicon border-effect"; - return "favicon"; - } - - if (node.url) { - return span({ class: contentClasses.join(' ') }, - a({ - href: node.url, - onclick: handleNodeClick - }, - () => FaviconService.displayIcon(node) ? - img({ src: FaviconService.getIcon(node.url || '', node.icon), class: faviconClasses(node) }) : null, - node.name - ) - ); - } else { - return span({ - class: contentClasses.join(' '), - onclick: handleNodeClick, - style: AppState.editMode.val ? "cursor: pointer;" : "" - }, node.name); - } - } - - static renderNode(node: LinkNode, siblings?: LinkNode[], index?: number) { - const nodeClass = TreeService.hasChildren(node) - ? `${DOM_CLASSES.TREE_ITEM} ${DOM_CLASSES.TEXT_PARENT}` - : `${DOM_CLASSES.TREE_ITEM} ${DOM_CLASSES.TEXT_CHILD}`; - - const children: any[] = []; - - // Add up/down buttons if in edit mode and node has siblings - if (AppState.editMode.val && siblings && siblings.length > 1) { - const moveControls = div({ class: "move-controls" }); - const upButton = this.createMoveUpButton(node, siblings, index || 0); - const downButton = this.createMoveDownButton(node, siblings, index || 0); - - if (upButton) add(moveControls, upButton); - if (downButton) add(moveControls, downButton); - - children.push(moveControls); - } - - // Add the main node content - children.push(this.renderNodeContent(node)); - - // Add delete button if form is open and node has no children - if (AppState.editMode.val && !TreeService.hasChildren(node)) { - children.push(this.createDeleteButton(node)); - } - - // Add children container if needed - if (TreeService.hasChildren(node) && node.children) { - children.push(this.renderChildList(node.children)); - } - - // Create list item with conditional right-click handler - const liProps: Record = { - id: 'listchild-' + node.name, - class: nodeClass - }; - - // Only add the right-click handler if the feature is enabled in settings - if (AppState.settings.val.enableRightClickComplete) { - liProps.oncontextmenu = (e: Event) => { - e.preventDefault(); - e.stopImmediatePropagation(); - AppState.toggleTaskComplete(node); - StorageService.save(AppState.rawList.val) - .catch(error => { - console.error('Failed to save task status:', error); - AppState.toggleTaskComplete(node); // Revert on failure - alert('Failed to update task status. Please try again.'); - }); - }; - } - - return li(liProps, ...children); - } - - static renderChildList(children: LinkNode[]) { - return ul({ class: DOM_CLASSES.TREE_LIST }, - ...children.map((child, index) => this.renderNode(child, children, index)) - ); - } - - static renderTree() { - const tree = AppState.root.val; - if (!tree.children || tree.children.length === 0) { - return div({ id: "lists-container", class: "row main-content" }); - } - - const listGroups: any[] = []; - let treeIndex = 0; - - for (const node of tree.children) { - const rootChildren = tree.children; - listGroups.push( - ul({ - id: `list-group-${treeIndex}`, - class: `${DOM_CLASSES.TREE_LIST} col` - }, this.renderNode(node, rootChildren, treeIndex)) - ); - treeIndex++; - } - - return div({ id: "lists-container", class: "row main-content" }, ...listGroups); - } - - static renderToggleButton() { - return a({ - id: "toggle-form-btn", - href: "#", - onclick: (e: Event) => { - e.preventDefault(); - AppState.editMode.val = !AppState.editMode.val; - AppState.editingNode.val = null; - - // Close settings mode if open - if (AppState.settingsMode.val) { - AppState.settingsMode.val = false; - } - }, }, - () => AppState.editMode.val ? `[−]` : `[+]` - ); - } - - static renderSidePanel() { - return div({ class: "row row-side-panel" }, - div({ class: "col" }, - div({}, - this.renderToggleButton(), - ), - EditForm.renderAddForm() - ) - ); - } + }, + () => UIState.editMode.val ? `[−]` : `[+]` + ); +} - static renderOverlay() { - return div({ id: "overlay-container" }, - AppState.settingsMode.val ? this.settingsComponent.renderSettingsPage() : null - ) - } - - static renderMain() { - return div({}, - // Main row containing the content - div({ class: "row" }, - // Main content area - will be populated by renderMainContent - () => this.renderTree(), - // Side panel with buttons and form TODO: Move this to overlay - UiComponents.renderSidePanel() +function renderSidePanel() { + return div({ class: "row row-side-panel" }, + div({ class: "col" }, + div({}, + renderToggleButton(), ), - ); - } + EditForm.renderAddForm() + ) + ); } -function handleListUpdate(list: LinkNodeFlat[]) { - AppState.rawList.val = list; - StorageService.applyNodeDefaults(list); - AppState.updateNames(); - AppState.root.val = TreeService.buildTree(list); - - FaviconService.shouldRequestPermission().then( result => { - if (result) { - AppState.addFooterMessage('request-favicon-permission'); - } else { - AppState.removeFooterMessage('request-favicon-permission'); - } - }); +function renderOverlay() { + return div({ id: "overlay-container" }, + UIState.settingsMode.val ? settingsComponent.renderSettingsPage() : null + ); } -function handleSettingsUpdate(settings: Settings) { - AppState.settings.val = settings; - applyTheme(settings.theme); +function renderMain() { + return div({}, + div({ class: "row" }, + () => TreeComponent.renderTree(), + renderSidePanel() + ), + ); } -// Initialize application async function initializeApp(): Promise { try { - // Initialize state with stored data + // Load SVG icons and stored data in parallel const [storedList, storedSettings] = await Promise.all([ StorageService.load(), - StorageService.loadSettings() + StorageService.loadSettings(), + loadSvgIcons(), ]); - handleListUpdate(storedList); - handleSettingsUpdate(storedSettings); - // Render the entire application using VanJS - // This creates the complete DOM structure including overlay container, main content, and footer - // Review: Consider moving this to the start instead of waiting for storage to load + await CommandService.handleListUpdate(storedList); + CommandService.handleSettingsUpdate(storedSettings); + add(document.body, - () => UiComponents.renderOverlay(), - UiComponents.renderMain(), + () => renderOverlay(), + renderMain(), renderFooter(), ); StorageService.printStartupInfo(); - + chrome.storage.onChanged.addListener((changes, areaName) => { if (areaName === 'sync') { console.log('storage changed', changes); - // Handle link list changes if (changes[CURRENT_LIST_VERSION]) { const newList = changes[CURRENT_LIST_VERSION].newValue as LinkNodeFlat[] || []; - handleListUpdate(newList); + CommandService.handleListUpdate(newList); } - - // Handle settings changes + if (changes[SETTINGS_VERSION]) { const newSettings = changes[SETTINGS_VERSION].newValue as Settings; - handleSettingsUpdate(newSettings); + CommandService.handleSettingsUpdate(newSettings); } } }); - addEventListener("keydown", async (event: KeyboardEvent) => { + addEventListener("keydown", (event: KeyboardEvent) => { if (event.key === "Escape") { - AppState.editMode.val = false; - AppState.editingNode.val = null; - AppState.settingsMode.val = false; + UIState.editMode.val = false; + UIState.editingNode.val = null; + UIState.settingsMode.val = false; } else if (event.key === "`" || event.key === "~") { - AppState.editMode.val = !AppState.editMode.val; - AppState.editingNode.val = null; + UIState.editMode.val = !UIState.editMode.val; + UIState.editingNode.val = null; } }); - + console.log('Application initialized successfully'); } catch (error) { console.error('Failed to initialize application:', error); @@ -359,6 +110,4 @@ async function initializeApp(): Promise { } } - -// Start the application -initializeApp().catch(console.error); \ No newline at end of file +initializeApp().catch(console.error); diff --git a/ts/components/edit-form.component.ts b/ts/components/edit-form.component.ts new file mode 100644 index 0000000..2a746a6 --- /dev/null +++ b/ts/components/edit-form.component.ts @@ -0,0 +1,187 @@ +import { DOM_CLASSES, FAVICON_PROVIDER_NAMES } from '../core/constants'; +import { FaviconProvider, LinkNodeFlat } from '../core/types'; +import { state, derive, form, div, label, br, input, select, option, ul, li } from '../core/van'; +import { CommandService } from '../services/command.service'; +import { ValidatorService } from '../services/validator.service'; +import { DataState } from '../state/data.state'; +import { UIState } from '../state/ui.state'; + +export class EditForm { + static renderAddForm() { + const nameField = state(''); + const urlField = state(''); + const parentField = state(''); + const iconField = state(''); + const borderField = state(1); + + const isEditing = () => UIState.editingNode.val !== null; + const originalName = state(''); + + const resetForm = () => { + nameField.val = ''; + urlField.val = ''; + parentField.val = ''; + iconField.val = DataState.settings.val.defaultFaviconProvider; + borderField.val = 1; + originalName.val = ''; + }; + + derive(() => { + const editNode = UIState.editingNode.val; + if (editNode) { + nameField.val = editNode.name; + urlField.val = editNode.url || ''; + parentField.val = editNode.parent || ''; + iconField.val = editNode.icon || DataState.settings.val.defaultFaviconProvider; + borderField.val = editNode.border ?? 1; + originalName.val = editNode.name; + } else { + resetForm(); + } + }); + + const handleSubmit = async (e: Event) => { + e.preventDefault(); + + const name = nameField.val.trim(); + const url = urlField.val.trim(); + const parent = parentField.val.trim(); + const icon = iconField.val as FaviconProvider; + const border = borderField.val; + + let errorMessage: string | null = null; + + if (name.length === 0) { + errorMessage = 'Name must be populated'; + } else if (!isEditing() && DataState.names.val.includes(name)) { + errorMessage = 'Name already taken'; + } else if (isEditing() && name !== originalName.val && DataState.names.val.includes(name)) { + errorMessage = 'Name already taken'; + } else if (url.length > 0 && !ValidatorService.isValidUrl(url)) { + errorMessage = 'URL format invalid'; + } else if (parent.length > 0 && !DataState.names.val.includes(parent)) { + errorMessage = 'Parent does not exist'; + } + + if (errorMessage) { + alert(errorMessage); + return; + } + + const item: LinkNodeFlat = { + name, + url: url || undefined, + parent: parent || undefined, + icon, + border: border as 0 | 1, + }; + + if (isEditing() && UIState.editingNode.val?.taskComplete) { + item.taskComplete = UIState.editingNode.val.taskComplete; + } + + if (isEditing()) { + const newDefaultIcon = icon !== DataState.settings.val.defaultFaviconProvider ? icon : undefined; + await CommandService.updateItem(originalName.val, item, newDefaultIcon); + } else { + await CommandService.addItem(item); + } + + UIState.editingNode.val = null; + resetForm(); + }; + + return form({ + id: "newlink-form", + class: () => UIState.editMode.val ? "" : DOM_CLASSES.DISPLAY_NONE, + onsubmit: handleSubmit + }, + div({ class: "form-header" }, + () => isEditing() ? "Edit Item" : "Add New Item", + ), + + label({ for: "newlink-name" }, "Name:"), br(), + input({ + type: "text", + id: "newlink-name", + name: "newlink-name", + autocomplete: "off", + value: nameField, + oninput: (e: Event) => nameField.val = (e.target as HTMLInputElement).value + }), br(), + + label({ for: "newlink-url" }, "URL (optional):"), br(), + input({ + type: "text", + id: "newlink-url", + name: "newlink-url", + autocomplete: "off", + value: urlField, + oninput: (e: Event) => urlField.val = (e.target as HTMLInputElement).value + }), br(), + + label({ for: "newlink-parent" }, "Parent (optional):"), br(), + input({ + type: "text", + id: "newlink-parent", + name: "newlink-parent", + autocomplete: "off", + value: parentField, + oninput: (e: Event) => parentField.val = (e.target as HTMLInputElement).value + }), br(), + + // Icon dropdown + label({ for: "newlink-icon" }, "Favicon options (for URLs):"), br(), + div({ class: "select-wrapper" }, + select({ + id: "newlink-icon", + name: "newlink-icon", + value: iconField, + onchange: (e: Event) => iconField.val = (e.target as HTMLSelectElement).value as FaviconProvider + }, + ...Object.keys(FAVICON_PROVIDER_NAMES).map((value: string) => + option({ + value, + selected: () => iconField.val === value + }, + FAVICON_PROVIDER_NAMES[value as keyof typeof FAVICON_PROVIDER_NAMES]) + ) + ), + br(), + select({ + id: "newlink-border", + name: "newlink-border", + value: borderField, + onchange: (e: Event) => borderField.val = parseInt((e.target as HTMLSelectElement).value) + }, + option({ value: "1" }, "Bordered"), + option({ value: "0" }, "No border") + ) + ), br(), + + div({ class: "form-actions" }, + input({ + type: "submit", + value: () => isEditing() ? "Update" : "Add" + }), + + () => isEditing() ? + input({ + type: "button", + value: "Cancel", + onclick: () => { + UIState.editingNode.val = null; + resetForm(); + } + }) : null + ), + + ul({ class: "form-hint" }, + li({}, "Click on any highlighted item to edit it"), + li({}, "Click the [-] icon next to an item to delete it"), + li({}, "Press ESC or the close button to exit edit mode"), + li({}, "Press ~ to toggle edit mode") + ) + ); + } +} diff --git a/ts/components/footer.component.ts b/ts/components/footer.component.ts new file mode 100644 index 0000000..766eb16 --- /dev/null +++ b/ts/components/footer.component.ts @@ -0,0 +1,36 @@ +import { FaviconService } from '../services/favicon.service'; +import { UIState } from '../state/ui.state'; +import { a, derive, div, span } from '../core/van'; + +export function renderFooter() { + const messages = derive(() => { + const messageList: any[] = []; + + if (UIState.footerMessages.val.has('request-favicon-permission')) { + messageList.push([ + a({ + href: "#", + onclick: () => FaviconService.requestFaviconPermissions() + }, + span({}, "Action required: Grant permission to use the chrome favicon cache. "), + span({}, "Alternatively, change all icons to use a different provider.")) + ]); + } + + return messageList; + }); + + return div({ class: "footer" }, + () => div(messages.val), + div( + a({ + href: "#", + onclick: (e: Event) => { + e.preventDefault(); + UIState.settingsMode.val = !UIState.settingsMode.val; + if (UIState.editMode.val) UIState.editMode.val = false; + } + }, "[settings]"), + ) + ); +} diff --git a/ts/settings.component.ts b/ts/components/settings.component.ts similarity index 57% rename from ts/settings.component.ts rename to ts/components/settings.component.ts index ef60891..19de4d0 100644 --- a/ts/settings.component.ts +++ b/ts/components/settings.component.ts @@ -1,60 +1,38 @@ -import { DOM_CLASSES, ICONS } from './constants'; -import { AppState } from './app.state'; -import { StorageService } from './storage.service'; -import { applyTheme } from './theme.service'; -import { Settings } from './types'; -import { a, div, h2, h3, input, label, p, select, option, br } from './van' +import { SVG_ICONS } from '../core/constants'; +import { Settings } from '../core/types'; +import { a, br, div, h2, h3, input, label, p, select, option } from '../core/van'; +import { CommandService } from '../services/command.service'; +import { DataState } from '../state/data.state'; +import { UIState } from '../state/ui.state'; export class SettingsComponent { - - constructor(readonly state: AppState) { - // console.log("Instantiating settings component") - } - renderSettingsPage() { - console.log("Rendering settings page"); - - const updateSetting = (key: keyof Settings, value: any) => { - const newSettings = { ...AppState.settings.val, [key]: value }; - AppState.settings.val = newSettings; - - // Apply theme immediately if it changes - if (key === 'theme') { - applyTheme(value); - } - - // Save settings to storage - StorageService.saveSettings(newSettings) - .catch(error => { - console.error('Failed to save settings:', error); - alert('Failed to save settings. Please try again.'); - }); + const updateSetting = (key: K, value: Settings[K]) => { + CommandService.updateSetting(key, value); }; - // Create the settings form - return div({ + return div({ class: `modal`, onclick: (e: Event) => { - // Close modal when clicking the backdrop (outside the modal) if ((e.target as HTMLElement).classList.contains('modal')) { - AppState.settingsMode.val = false; + UIState.settingsMode.val = false; } } - }, - div({ class: "settings-page" }, - div({ class: "settings-header" }, + }, + div({ class: "settings-page" }, + div({ class: "settings-header" }, h2({}, "Settings"), - a({ - href: "#", + a({ + href: "#", class: "close-settings-btn", onclick: (e: Event) => { e.preventDefault(); - AppState.settingsMode.val = false; + UIState.settingsMode.val = false; }, - innerHTML: ICONS.CLOSE + innerHTML: SVG_ICONS['close'] }) ), - + // Right-click Complete Setting div({ class: "settings-group" }, h3({}, "Task Completion"), @@ -63,19 +41,19 @@ export class SettingsComponent { input({ type: "checkbox", id: "right-click-toggle", - checked: AppState.settings.val.enableRightClickComplete, + checked: DataState.settings.val.enableRightClickComplete, onchange: (e: Event) => { updateSetting('enableRightClickComplete', (e.target as HTMLInputElement).checked); } }), "Right-click to Mark as Done" ), - p({ class: "setting-description" }, + p({ class: "setting-description" }, "When enabled, right-clicking on an item will apply a strike-through style to the text." ) ) ), - + // Theme Setting div({ class: "settings-group" }, h3({}, "Theme"), @@ -87,7 +65,7 @@ export class SettingsComponent { id: "theme-light", name: "theme", value: "light", - checked: AppState.settings.val.theme === 'light', + checked: DataState.settings.val.theme === 'light', onchange: () => updateSetting('theme', 'light') }), "Light" @@ -98,7 +76,7 @@ export class SettingsComponent { id: "theme-dark", name: "theme", value: "dark", - checked: AppState.settings.val.theme === 'dark', + checked: DataState.settings.val.theme === 'dark', onchange: () => updateSetting('theme', 'dark') }), "Dark" @@ -109,13 +87,13 @@ export class SettingsComponent { id: "theme-system", name: "theme", value: "system", - checked: AppState.settings.val.theme === 'system', + checked: DataState.settings.val.theme === 'system', onchange: () => updateSetting('theme', 'system') }), "System (Default)" ) ), - p({ class: "setting-description" }, + p({ class: "setting-description" }, "Choose your preferred theme or use your system's setting." ) ) @@ -125,12 +103,11 @@ export class SettingsComponent { div({ class: "settings-group" }, div({ class: "setting-description" }, () => { - const manifest = chrome.runtime.getManifest() - return `Version ${manifest.version}` + const manifest = chrome.runtime.getManifest(); + return `Version ${manifest.version}`; }) ) ) ); } - -} \ No newline at end of file +} diff --git a/ts/components/tree.component.ts b/ts/components/tree.component.ts new file mode 100644 index 0000000..4423128 --- /dev/null +++ b/ts/components/tree.component.ts @@ -0,0 +1,153 @@ +import { DOM_CLASSES, ICONS } from '../core/constants'; +import { LinkNode, LinkNodeFlat } from '../core/types'; +import { add, a, div, img, li, span, ul } from '../core/van'; +import { CommandService } from '../services/command.service'; +import { FaviconService } from '../services/favicon.service'; +import { TreeService } from '../services/tree.service'; +import { DataState } from '../state/data.state'; +import { UIState } from '../state/ui.state'; + +export class TreeComponent { + + static renderDeleteButton(node: LinkNodeFlat) { + return a({ href: "#", onclick: (e: Event) => { + e.preventDefault(); + CommandService.deleteNode(node); + }}, ICONS.MINUS); + } + + static renderMoveUpButton(node: LinkNode, siblings: LinkNode[], index: number) { + if (index <= 0) return null; + + return a({ + href: "#", + class: "move-btn move-up", + onclick: (e: Event) => { + e.preventDefault(); + CommandService.moveNode(node, siblings[index - 1]); + } + }, ICONS.UP); + } + + static renderMoveDownButton(node: LinkNode, siblings: LinkNode[], index: number) { + if (index >= siblings.length - 1) return null; + + return a({ + href: "#", + class: "move-btn move-down", + onclick: (e: Event) => { + e.preventDefault(); + CommandService.moveNode(node, siblings[index + 1]); + } + }, ICONS.DOWN); + } + + static renderNodeContent(node: LinkNode) { + const contentClasses = []; + if (node.taskComplete) contentClasses.push(DOM_CLASSES.TEXT_LINETHROUGH); + if (UIState.editMode.val) contentClasses.push('editable-node'); + + const handleNodeClick = (e: Event) => { + if (UIState.editMode.val) { + e.preventDefault(); + e.stopPropagation(); + UIState.editingNode.val = node; + } + }; + + const faviconClasses = (node: LinkNodeFlat) => + node.border == 1 ? "favicon border-effect" : "favicon"; + + if (node.url) { + return span({ class: contentClasses.join(' ') }, + a({ + href: node.url, + onclick: handleNodeClick + }, + () => FaviconService.displayIcon(node) ? + img({ src: FaviconService.getIcon(node.url || '', node.icon), class: faviconClasses(node) }) : null, + node.name + ) + ); + } else { + return span({ + class: contentClasses.join(' '), + onclick: handleNodeClick, + style: UIState.editMode.val ? "cursor: pointer;" : "" + }, node.name); + } + } + + static renderNode(node: LinkNode, siblings?: LinkNode[], index?: number) { + const nodeClass = TreeService.hasChildren(node) + ? `${DOM_CLASSES.TREE_ITEM} ${DOM_CLASSES.TEXT_PARENT}` + : `${DOM_CLASSES.TREE_ITEM} ${DOM_CLASSES.TEXT_CHILD}`; + + const children: any[] = []; + + if (UIState.editMode.val && siblings && siblings.length > 1) { + const moveControls = div({ class: "move-controls" }); + const upButton = this.renderMoveUpButton(node, siblings, index || 0); + const downButton = this.renderMoveDownButton(node, siblings, index || 0); + + if (upButton) add(moveControls, upButton); + if (downButton) add(moveControls, downButton); + + children.push(moveControls); + } + + children.push(this.renderNodeContent(node)); + + if (UIState.editMode.val && !TreeService.hasChildren(node)) { + children.push(this.renderDeleteButton(node)); + } + + if (TreeService.hasChildren(node) && node.children) { + children.push(this.renderChildList(node.children)); + } + + const liProps: Record = { + id: 'listchild-' + node.name, + class: nodeClass + }; + + if (DataState.settings.val.enableRightClickComplete) { + liProps.oncontextmenu = (e: Event) => { + e.preventDefault(); + e.stopImmediatePropagation(); + CommandService.toggleTaskComplete(node); + }; + } + + return li(liProps, ...children); + } + + static renderChildList(children: LinkNode[]) { + return ul({ class: DOM_CLASSES.TREE_LIST }, + ...children.map((child, index) => this.renderNode(child, children, index)) + ); + } + + static renderTree() { + const tree = DataState.root.val; + if (!tree.children || tree.children.length === 0) { + return div({ id: "lists-container", class: "row main-content" }); + } + + const listGroups: any[] = []; + let treeIndex = 0; + + for (const node of tree.children) { + const rootChildren = tree.children; + listGroups.push( + ul({ + id: `list-group-${treeIndex}`, + class: `${DOM_CLASSES.TREE_LIST} col` + }, this.renderNode(node, rootChildren, treeIndex)) + ); + treeIndex++; + } + + return div({ id: "lists-container", class: "row main-content" }, ...listGroups); + } +} diff --git a/ts/constants.ts b/ts/constants.ts deleted file mode 100644 index 0b2a48a..0000000 --- a/ts/constants.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { FaviconProvider, Settings } from './types'; - -// Constants -export const CURRENT_LIST_VERSION = 'links-v1'; -export const SETTINGS_VERSION = 'settings-v1'; -export const DOM_CLASSES = { - DISPLAY_NONE: 'display-none', - TREE_LIST: 'tree-list', - TREE_ITEM: 'tree-item', - TEXT_PARENT: 'text-parent', - TEXT_CHILD: 'text-child', - TEXT_LINETHROUGH: 'text-linethrough', - WELCOME_MESSAGE: 'welcome-message', - SETTINGS_PAGE: 'settings-page', - BUTTON_GROUP: 'button-group' -}; - -export const ICONS = { - MINUS: '[\u2212]', - PLUS: '[+]', - UP: '↑', - DOWN: '↓', - EDIT: ` - - `, - CLOSE: ` - - - `, - SETTINGS: ` - - - `, - LINK: ` - - - ` -}; - -export const FAVICON_PROVIDER_NAMES: { [key in FaviconProvider]: string } = { - [FaviconProvider.Chrome]: 'Dynamic (Chrome cache)', - [FaviconProvider.DuckDuckGo]: 'Dynamic (DuckDuckGo API)', - [FaviconProvider.Generic]: 'Generic link icon', - [FaviconProvider.None]: 'None (disabled)', -} - -export const DEFAULT_SETTINGS: Settings = { - defaultFaviconProvider: Object.keys(FAVICON_PROVIDER_NAMES)[0] as FaviconProvider, - enableRightClickComplete: true, - theme: 'system' -} - diff --git a/ts/core/constants.ts b/ts/core/constants.ts new file mode 100644 index 0000000..2bd3248 --- /dev/null +++ b/ts/core/constants.ts @@ -0,0 +1,47 @@ +import { FaviconProvider, Settings } from './types'; + +// Storage key versions +export const CURRENT_LIST_VERSION = 'links-v1'; +export const SETTINGS_VERSION = 'settings-v1'; + +export const DOM_CLASSES = { + DISPLAY_NONE: 'display-none', + TREE_LIST: 'tree-list', + TREE_ITEM: 'tree-item', + TEXT_PARENT: 'text-parent', + TEXT_CHILD: 'text-child', + TEXT_LINETHROUGH: 'text-linethrough', +}; + +// Text-based icons used inline +export const ICONS = { + MINUS: '[\u2212]', + PLUS: '[+]', + UP: '↑', + DOWN: '↓', +}; + +// SVG icons loaded from files — innerHTML strings built at init time +export const SVG_ICONS: Record = {}; + +export async function loadSvgIcons(): Promise { + const iconNames = ['edit', 'close', 'settings', 'link']; + await Promise.all(iconNames.map(async (name) => { + const url = chrome.runtime.getURL(`/static/icons/svg/${name}.svg`); + const response = await fetch(url); + SVG_ICONS[name] = await response.text(); + })); +} + +export const FAVICON_PROVIDER_NAMES: { [key in FaviconProvider]: string } = { + [FaviconProvider.Chrome]: 'Dynamic (Chrome cache)', + [FaviconProvider.DuckDuckGo]: 'Dynamic (DuckDuckGo API)', + [FaviconProvider.Generic]: 'Generic link icon', + [FaviconProvider.None]: 'None (disabled)', +}; + +export const DEFAULT_SETTINGS: Settings = { + defaultFaviconProvider: Object.keys(FAVICON_PROVIDER_NAMES)[0] as FaviconProvider, + enableRightClickComplete: true, + theme: 'system', +}; diff --git a/ts/types.ts b/ts/core/types.ts similarity index 80% rename from ts/types.ts rename to ts/core/types.ts index a90c0b4..c9b7e2a 100644 --- a/ts/types.ts +++ b/ts/core/types.ts @@ -1,4 +1,3 @@ -// Types and Interfaces export interface LinkNodeFlat { name: string; url?: string; @@ -30,9 +29,13 @@ export interface Settings { theme: 'light' | 'dark' | 'system'; } +// NOTE: The string values here are persisted in chrome.storage.sync. +// Changing them would break settings for existing users. export enum FaviconProvider { Chrome = 'chrome', DuckDuckGo = 'duck', Generic = 'gen', None = 'none', } + +export type FooterMessage = 'request-favicon-permission'; diff --git a/ts/van.ts b/ts/core/van.ts similarity index 66% rename from ts/van.ts rename to ts/core/van.ts index ba12829..d51da11 100644 --- a/ts/van.ts +++ b/ts/core/van.ts @@ -1,10 +1,6 @@ // Importing directly costs 5kb bundle size or 10kb with vanjs-core/debug import van from 'vanjs-core' -// If importing via