feat(links): Links Apps view — embed + update-tracker + quick-add
This commit is contained in:
@@ -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')
|
||||
};
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
@@ -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
39
public/views/links.js
Normal 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'; }
|
||||
}
|
||||
28
tests/frontend/links_view.test.js
Normal file
28
tests/frontend/links_view.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user