feat(links): Links Apps view — embed + update-tracker + quick-add

This commit is contained in:
root
2026-06-08 23:29:35 +10:00
parent cd5ca03d96
commit 318492a078
6 changed files with 77 additions and 1 deletions

View File

@@ -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')
};

View File

@@ -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')
)
);

View File

@@ -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: [] }

View File

@@ -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; }

39
public/views/links.js Normal file
View File

@@ -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'; }
}

View File

@@ -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('<!doctype html><html><body><div id="main"></div></body></html>', { 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();
});
});