AcadeDoc/apps/extension-clipper/src/popup/popup.ts
Corentin 9dd283ced6 refactor(acadedoc): rename API routes /api/acadenice -> /api/v1 — R5.1
Replace all @Controller('acadenice/...') decorators with 'v1/...' on 16 NestJS controllers. Update all client services, hooks, tests, extension-clipper, and doc comments to match. DB table names (acadenice_*) and folder structure untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:52:49 +02:00

303 lines
9.8 KiB
TypeScript

/**
* Popup entry point (vanilla TypeScript, no framework).
*
* Renders:
* - Tab: Clip — title, space selector, parent page, selection preview, clip button
* - Tab: Settings — API URL, token, default workspace/space IDs
*
* Flow:
* 1. On open: load settings, query the active tab for page data.
* 2. User fills in form and clicks "Clip".
* 3. sendClip() hits POST /api/v1/clipper/import.
* 4. On success: show result link. On error: show typed error message.
*/
import { getSettings, saveSettings } from '../lib/storage';
import { sendClip, ClipPayload } from '../lib/api-client';
import { t } from '../i18n/messages';
interface PageData {
url: string;
title: string;
htmlSelection: string;
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let pageData: PageData = { url: '', title: '', htmlSelection: '' };
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function el<T extends HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) throw new Error(`Element #${id} not found in popup DOM`);
return node as T;
}
function setText(id: string, text: string): void {
el(id).textContent = text;
}
function setHidden(id: string, hidden: boolean): void {
el(id).hidden = hidden;
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
async function init(): Promise<void> {
renderSkeleton();
const settings = await getSettings();
bindSettingsTab(settings);
await loadPageData();
bindClipTab(settings);
bindTabs();
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function renderSkeleton(): void {
const app = document.getElementById('app')!;
app.innerHTML = `
<div class="header">
<span class="header-title">${t('title')}</span>
</div>
<div class="tabs">
<button class="tab-btn active" data-tab="clip">${t('tabTitle')}</button>
<button class="tab-btn" data-tab="settings">${t('tabSettings')}</button>
</div>
<!-- Clip tab -->
<div id="tab-clip" class="tab-panel active">
<div class="form-group">
<label for="inp-title">${t('labelTitle')}</label>
<input type="text" id="inp-title" />
</div>
<div class="form-group">
<label for="inp-space">${t('labelSpace')}</label>
<input type="text" id="inp-space" placeholder="Space UUID" />
</div>
<div class="form-group">
<label for="inp-parent">${t('labelParent')}</label>
<input type="text" id="inp-parent" placeholder="${t('placeholderParent')}" />
</div>
<div class="form-group">
<label>${t('labelSelection')}</label>
<span id="sel-badge" class="selection-badge">${t('selectionEmpty')}</span>
</div>
<div id="clip-error" class="alert alert-error" hidden></div>
<div id="clip-success" class="alert alert-success" hidden></div>
<button id="btn-clip" class="btn btn-primary" disabled>
${t('btnClip')}
</button>
</div>
<!-- Settings tab -->
<div id="tab-settings" class="tab-panel">
<div class="form-group">
<label for="cfg-url">${t('labelApiUrl')}</label>
<input type="url" id="cfg-url" />
</div>
<div class="form-group">
<label for="cfg-token">${t('labelApiToken')}</label>
<input type="password" id="cfg-token" />
</div>
<div class="form-group">
<label for="cfg-ws">${t('labelDefaultWorkspace')}</label>
<input type="text" id="cfg-ws" placeholder="UUID" />
</div>
<div class="form-group">
<label for="cfg-space">${t('labelDefaultSpace')}</label>
<input type="text" id="cfg-space" placeholder="UUID" />
</div>
<div id="settings-saved" class="alert alert-success" hidden>${t('settingsSaved')}</div>
<button id="btn-save" class="btn btn-primary">${t('btnSaveSettings')}</button>
</div>
`;
}
// ---------------------------------------------------------------------------
// Page data
// ---------------------------------------------------------------------------
async function loadPageData(): Promise<void> {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
const response = await chrome.tabs.sendMessage(tab.id, {
type: 'GET_PAGE_DATA',
});
if (response) {
pageData = response as PageData;
}
} catch {
// Content script not yet injected — use tab URL as fallback
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
pageData = {
url: tab?.url ?? '',
title: tab?.title ?? '',
htmlSelection: '',
};
}
}
// ---------------------------------------------------------------------------
// Clip tab binding
// ---------------------------------------------------------------------------
function bindClipTab(settings: Awaited<ReturnType<typeof getSettings>>): void {
const titleInput = el<HTMLInputElement>('inp-title');
const spaceInput = el<HTMLInputElement>('inp-space');
const parentInput = el<HTMLInputElement>('inp-parent');
const selBadge = el('sel-badge');
const btnClip = el<HTMLButtonElement>('btn-clip');
titleInput.value = pageData.title;
spaceInput.value = settings.defaultSpaceId;
if (pageData.htmlSelection) {
selBadge.textContent = t('selectionPresent');
selBadge.classList.add('has-selection');
}
const canClip = () =>
!!titleInput.value.trim() &&
!!spaceInput.value.trim() &&
!!settings.apiToken &&
!!settings.defaultWorkspaceId;
const updateButton = () => {
btnClip.disabled = !canClip();
};
titleInput.addEventListener('input', updateButton);
spaceInput.addEventListener('input', updateButton);
updateButton();
btnClip.addEventListener('click', async () => {
const errorEl = el('clip-error');
const successEl = el('clip-success');
errorEl.hidden = true;
successEl.hidden = true;
btnClip.disabled = true;
btnClip.innerHTML = `<span class="spinner"></span> ${t('btnClipping')}`;
const payload: ClipPayload = {
url: pageData.url,
title: titleInput.value.trim(),
html_selection: pageData.htmlSelection || undefined,
target_workspace_id: settings.defaultWorkspaceId,
target_space_id: spaceInput.value.trim(),
target_parent_page_id: parentInput.value.trim() || undefined,
};
try {
const result = await sendClip(settings.apiUrl, settings.apiToken, payload);
const pageUrl = `${settings.apiUrl}/p/${result.slugId}`;
successEl.innerHTML = `
${t('successTitle')}
<a class="alert-link" href="${pageUrl}" target="_blank" rel="noopener noreferrer">
${t('successOpen')}
</a>
`;
successEl.hidden = false;
} catch (err: unknown) {
const statusCode =
err != null && typeof err === 'object' && 'statusCode' in err
? (err as { statusCode: number }).statusCode
: 0;
let msg = t('errorGeneric');
if (statusCode === 401 || statusCode === 403) {
msg = t('errorUnauthorized');
} else if (err instanceof Error && err.message.startsWith('Network')) {
msg = t('errorNetwork');
} else if (err != null && typeof err === 'object' && 'message' in err) {
msg = String((err as { message: string }).message);
}
errorEl.textContent = msg;
errorEl.hidden = false;
} finally {
btnClip.disabled = !canClip();
btnClip.textContent = t('btnClip');
}
});
}
// ---------------------------------------------------------------------------
// Settings tab binding
// ---------------------------------------------------------------------------
function bindSettingsTab(
settings: Awaited<ReturnType<typeof getSettings>>,
): void {
el<HTMLInputElement>('cfg-url').value = settings.apiUrl;
el<HTMLInputElement>('cfg-token').value = settings.apiToken;
el<HTMLInputElement>('cfg-ws').value = settings.defaultWorkspaceId;
el<HTMLInputElement>('cfg-space').value = settings.defaultSpaceId;
el('btn-save').addEventListener('click', async () => {
await saveSettings({
apiUrl: el<HTMLInputElement>('cfg-url').value.trim(),
apiToken: el<HTMLInputElement>('cfg-token').value.trim(),
defaultWorkspaceId: el<HTMLInputElement>('cfg-ws').value.trim(),
defaultSpaceId: el<HTMLInputElement>('cfg-space').value.trim(),
});
const savedEl = el('settings-saved');
savedEl.hidden = false;
setTimeout(() => { savedEl.hidden = true; }, 2000);
});
}
// ---------------------------------------------------------------------------
// Tab switching
// ---------------------------------------------------------------------------
function bindTabs(): void {
document.querySelectorAll<HTMLButtonElement>('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const target = btn.dataset['tab'];
if (!target) return;
document.querySelectorAll('.tab-btn').forEach((b) =>
b.classList.remove('active'),
);
document.querySelectorAll('.tab-panel').forEach((p) =>
p.classList.remove('active'),
);
btn.classList.add('active');
document.getElementById(`tab-${target}`)?.classList.add('active');
});
});
}
// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
document.addEventListener('DOMContentLoaded', () => {
init().catch(console.error);
});