diff --git a/public/app.js b/public/app.js index 2020e09..26f555f 100644 --- a/public/app.js +++ b/public/app.js @@ -28,6 +28,7 @@ const VIEWS = { timelapse: () => import('./views/timelapse.js'), 'ai-usage': () => import('./views/aiusage.js'), obd2: () => import('./views/obd2.js'), + links: () => import('./views/links.js'), settings: () => import('./views/settings.js'), jobs: () => import('./views/jobs.js') }; diff --git a/public/components/sidebar.js b/public/components/sidebar.js index a41a131..41d96e7 100644 --- a/public/components/sidebar.js +++ b/public/components/sidebar.js @@ -132,7 +132,8 @@ export function renderSidebar(root) { el('div', { class: 'sb-title' }, 'Apps'), navItem('Timelapse', '/timelapse'), navItem('AI Usage', '/ai-usage'), - navItem('OBD2', '/obd2') + navItem('OBD2', '/obd2'), + navItem('Links', '/links') ) ); diff --git a/public/router.js b/public/router.js index 5c85cd9..028b455 100644 --- a/public/router.js +++ b/public/router.js @@ -29,6 +29,7 @@ const ROUTES = [ { name: 'timelapse', re: /^\/timelapse$/, keys: [] }, { name: 'ai-usage', re: /^\/ai-usage$/, keys: [] }, { name: 'obd2', re: /^\/obd2$/, keys: [] }, + { name: 'links', re: /^\/links$/, keys: [] }, { name: 'settings', re: /^\/settings$/, keys: [] }, { name: 'jobs', re: /^\/jobs$/, keys: [] }, { name: 'home', re: /^\/?$/, keys: [] } diff --git a/public/style.css b/public/style.css index 3051ca6..f35bf96 100644 --- a/public/style.css +++ b/public/style.css @@ -563,6 +563,12 @@ body.drawer-open #scrim { opacity: 1; pointer-events: auto; } .dv-tile .dv-nm { font-family: var(--font-ui); font-size: 13px; color: var(--text); } .dv-tile .dv-ip { font-family: var(--font-mono); font-size: 12px; color: var(--muted); } .dv-tile .dv-mac { font-family: var(--font-mono); font-size: 10px; color: var(--muted); opacity: .6; letter-spacing: .02em; } +.lk-card { max-width: 760px; } +.lk-row { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; } +.lk-update a { color: var(--accent); } +.lk-quickadd { display: flex; gap: 8px; } +.lk-quickadd .lk-url { flex: 1; } +.lk-out { display: block; margin-top: 8px; font-family: var(--font-mono); font-size: 13px; } .dv-tile { position: relative; } .dv-edit-btn { position: absolute; top: 5px; right: 5px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 3px; font-size: 11px; line-height: 1; padding: 2px 5px; cursor: pointer; opacity: 0; } .dv-tile:hover .dv-edit-btn { opacity: 1; } diff --git a/public/views/links.js b/public/views/links.js new file mode 100644 index 0000000..adc5da9 --- /dev/null +++ b/public/views/links.js @@ -0,0 +1,39 @@ +// #/links — Hybrid Apps view: a Void-native card (update-tracker + quick-add) on +// top of the embedded themed Kutt UI. Reuses the .term-bar/.term-frame embed classes. +import { el, mount } from '../dom.js'; +import { api } from '../api.js'; + +const SRC = 'https://link.hynesy.com/'; + +export async function render(main) { + const badge = el('span', { class: 'lk-badge muted' }, 'checking…'); + const out = el('span', { class: 'lk-out muted' }, ''); + const input = el('input', { class: 'lk-url', placeholder: 'https://long-url-to-shorten…' }); + const add = el('button', { class: 'primary' }, '◆ Shorten'); + add.onclick = async () => { + const target = input.value.trim(); if (!target) return; + out.textContent = 'creating…'; + try { const r = await api.post('/api/kutt', { target }); out.innerHTML = ''; out.appendChild(el('a', { href: r.link, target: '_blank', rel: 'noopener' }, r.link)); input.value = ''; } + catch { out.textContent = 'failed (is Kutt reachable / API key set?)'; } + }; + + mount(main, + el('div', { class: 'term-bar' }, + el('span', { class: 'term-title' }, '◆ Links'), + el('a', { class: 'ghost', style: { marginLeft: 'auto' }, href: SRC, target: '_blank', rel: 'noopener' }, '↗ Open Kutt') + ), + el('div', { class: 'card lk-card' }, + el('div', { class: 'lk-row' }, el('span', { class: 'muted' }, 'Kutt version'), badge), + el('div', { class: 'lk-quickadd' }, input, add), + el('div', {}, out) + ), + el('iframe', { id: 'embed-frame', src: SRC, class: 'term-frame' }) + ); + + try { + const v = await api.get('/api/kutt/version'); + badge.classList.remove('muted'); + if (v.updateAvailable) { badge.classList.add('lk-update'); badge.innerHTML = ''; badge.appendChild(el('a', { href: v.url, target: '_blank', rel: 'noopener' }, `${v.running} → ${v.latest} · update available`)); } + else badge.textContent = `${v.running} · up to date`; + } catch { badge.textContent = 'version check unavailable'; } +} diff --git a/tests/frontend/links_view.test.js b/tests/frontend/links_view.test.js new file mode 100644 index 0000000..2ea12cb --- /dev/null +++ b/tests/frontend/links_view.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { JSDOM } from 'jsdom'; + +vi.mock('../../public/api.js', () => ({ api: { + get: vi.fn(async (p) => p.endsWith('/version') + ? { running: 'v3.2.5', latest: 'v3.2.6', updateAvailable: true, url: 'https://x' } + : { data: [] }), + post: vi.fn(async () => ({ link: 'https://link.hynesy.com/abc' })) +} })); + +let render; +beforeAll(async () => { + const dom = new JSDOM('
', { url: 'http://localhost/' }); + global.window = dom.window; global.document = dom.window.document; global.Node = dom.window.Node; + ({ render } = await import('../../public/views/links.js')); +}); +afterAll(() => { delete global.window; delete global.document; delete global.Node; }); + +describe('links view', () => { + it('renders the update badge + quick-add + the Kutt iframe', async () => { + const main = document.getElementById('main'); + await render(main); + await new Promise(r => setTimeout(r, 0)); + expect(main.querySelector('iframe.term-frame').getAttribute('src')).toBe('https://link.hynesy.com/'); + expect(main.textContent).toMatch(/update available/i); + expect(main.querySelector('.lk-quickadd')).not.toBeNull(); + }); +});