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>
303 lines
9.8 KiB
TypeScript
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);
|
|
});
|