diff --git a/config.php b/config.php index c4b3949..efe9748 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ +
slot('md-toolbar') ?> diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index a0d32d4..9d91d57 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -363,6 +363,7 @@ export function closeModal({ animate = true } = {}) { hideTagAc(); hideSnippetAc(); closeFindBar(); + closeAssetPicker(); modal.classList.remove('open'); state.editingId = null; state.returnToGalleryAfterEdit = false; @@ -1184,6 +1185,110 @@ $('md-upload-pdf-file')?.addEventListener('change', e => { e.target.value = ''; }); +// ── Asset picker (insert from uploads) ── + +const ASSET_IMAGE_EXT = /\.(png|jpe?g|gif|webp)$/i; +const ASSET_AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i; +const ASSET_PDF_EXT = /\.pdf$/i; + +function assetType(name) { + if (ASSET_IMAGE_EXT.test(name)) return 'image'; + if (ASSET_AUDIO_EXT.test(name)) return 'audio'; + if (ASSET_PDF_EXT.test(name)) return 'pdf'; + return 'other'; +} + +let assetPickerEl = null; + +function closeAssetPicker() { + if (assetPickerEl) { assetPickerEl.remove(); assetPickerEl = null; } +} + +$('md-asset-picker')?.addEventListener('click', async () => { + if (assetPickerEl) { closeAssetPicker(); return; } + + let files = []; + try { files = await api('api/uploads'); } catch {} + if (!Array.isArray(files)) files = []; + + const btn = $('md-asset-picker'); + const rect = btn.getBoundingClientRect(); + + const picker = document.createElement('div'); + picker.id = 'asset-picker-flyout'; + picker.style.cssText = `position:fixed;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;width:340px;max-height:400px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:6px;box-shadow:0 12px 40px rgba(0,0,0,0.5);z-index:200;display:flex;flex-direction:column;overflow:hidden;`; + + const searchHtml = `
+ +
`; + + const gridId = 'asset-picker-grid'; + picker.innerHTML = searchHtml + `
`; + document.body.appendChild(picker); + assetPickerEl = picker; + + const grid = picker.querySelector(`#${gridId}`); + const searchInput = picker.querySelector('#asset-picker-search'); + + function renderTiles(filter) { + const q = (filter || '').toLowerCase(); + const filtered = q ? files.filter(f => f.name.toLowerCase().includes(q)) : files; + if (!filtered.length) { + grid.innerHTML = `
${files.length ? 'no matches' : 'no assets'}
`; + return; + } + grid.innerHTML = filtered.map(f => { + const type = assetType(f.name); + const label = f.name.replace(/\.[^.]+$/, ''); + if (type === 'image') { + return `
+ +
`; + } + const icon = type === 'audio' ? 'ph-music-note' : type === 'pdf' ? 'ph-file-pdf' : 'ph-file'; + const iconColor = type === 'pdf' ? 'color:var(--t-danger)' : 'color:var(--t-dim)'; + return `
+ + ${esc(label)} +
`; + }).join(''); + + grid.querySelectorAll('.asset-pick-tile').forEach(tile => { + tile.addEventListener('click', () => { + const url = tile.dataset.url; + const name = tile.dataset.name; + const type = tile.dataset.type; + let md; + if (type === 'image') md = `![${name.replace(/\.[^.]+$/, '')}](${url})`; + else if (type === 'pdf') md = `[📄 ${name}](${url})`; + else if (type === 'audio') md = ``; + else md = `[${name}](${url})`; + insertMd(md); + closeAssetPicker(); + }); + tile.addEventListener('mouseenter', () => { tile.style.borderColor = 'var(--t-accent)'; }); + tile.addEventListener('mouseleave', () => { tile.style.borderColor = 'var(--t-border)'; }); + }); + } + + renderTiles(''); + setTimeout(() => searchInput.focus(), 0); + searchInput.addEventListener('input', () => renderTiles(searchInput.value)); + searchInput.addEventListener('keydown', e => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeAssetPicker(); } + }); + + const onClickOutside = e => { + if (!picker.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { + closeAssetPicker(); + document.removeEventListener('mousedown', onClickOutside); + } + }; + document.addEventListener('mousedown', onClickOutside); +}); + // ── Image resize ── document.addEventListener('mousedown', e => { diff --git a/public/plugins/gitea/backend.php b/public/plugins/gitea/backend.php index b1fcde3..7e26a8c 100644 --- a/public/plugins/gitea/backend.php +++ b/public/plugins/gitea/backend.php @@ -27,10 +27,16 @@ require_once __DIR__ . '/GiteaClient.php'; name TEXT NOT NULL, base_url TEXT NOT NULL, token TEXT NOT NULL, + default_repo TEXT DEFAULT NULL, position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) "); + // Migration: add default_repo column if missing + $cols = $db->query("PRAGMA table_info(gitea_accounts)")->fetchAll(PDO::FETCH_COLUMN, 1); + if (!in_array('default_repo', $cols)) { + $db->exec("ALTER TABLE gitea_accounts ADD COLUMN default_repo TEXT DEFAULT NULL"); + } })(); // ── Encryption helpers (mirrors email plugin / CalDavSync) ──────── @@ -85,7 +91,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { if ($route === '/api/gitea/accounts' && $method === 'GET') { $db = Database::connect(); - $rows = $db->query('SELECT id, name, base_url, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll(); + $rows = $db->query('SELECT id, name, base_url, default_repo, position, created_at FROM gitea_accounts ORDER BY position ASC, id ASC')->fetchAll(); giteaJson($rows); } @@ -132,6 +138,10 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) { if (!empty($input['token'])) { $fields[] = 'token = :token'; $params['token'] = giteaEncrypt($input['token']); } + if (array_key_exists('default_repo', $input)) { + $val = trim((string)($input['default_repo'] ?? '')); + $fields[] = 'default_repo = :default_repo'; $params['default_repo'] = $val ?: null; + } if (!$fields) giteaJson(['ok' => true]); $db = Database::connect(); $db->prepare('UPDATE gitea_accounts SET ' . implode(', ', $fields) . ' WHERE id = :id')->execute($params); diff --git a/public/plugins/gitea/gitea.js b/public/plugins/gitea/gitea.js index 95d4e4a..460d66a 100644 --- a/public/plugins/gitea/gitea.js +++ b/public/plugins/gitea/gitea.js @@ -113,6 +113,10 @@ function buildSettingsPanel(panelEl) { access token +
@@ -150,7 +154,7 @@ function renderAccountsList() {
- +
@@ -201,6 +205,7 @@ function resetAccountForm() { $('gitea-acc-name').value = ''; $('gitea-acc-baseurl').value = ''; $('gitea-acc-token').value = ''; + $('gitea-acc-default-repo').value = ''; setAccountStatus(''); $('gitea-tok-hint').textContent = ''; } @@ -214,6 +219,7 @@ function editAccount(id) { $('gitea-acc-name').value = acc.name; $('gitea-acc-baseurl').value = acc.base_url; $('gitea-acc-token').value = ''; + $('gitea-acc-default-repo').value = acc.default_repo || ''; $('gitea-tok-hint').textContent = '(leave blank to keep existing)'; setAccountStatus(''); $('gitea-acc-name').focus(); @@ -240,6 +246,7 @@ async function saveAccount(e) { name: $('gitea-acc-name').value.trim(), base_url: $('gitea-acc-baseurl').value.trim(), token: $('gitea-acc-token').value, + default_repo: $('gitea-acc-default-repo').value.trim(), }; if (!payload.name || !payload.base_url || (!editingAccountId && !payload.token)) { setAccountStatus('name, base url, and token are required', 'error'); @@ -713,8 +720,9 @@ async function openCreateForm() { $('gitea-create-form').classList.remove('hidden'); $('gitea-create-form').classList.add('flex'); - // Reset form - $('gitea-create-repo').value = ''; + // Reset form — pre-fill default repo if configured + const currentAccount = accounts.find(a => a.id === currentAccountId); + $('gitea-create-repo').value = currentAccount?.default_repo || ''; $('gitea-create-title').value = ''; $('gitea-create-body').value = ''; setCreateStatus(''); @@ -766,6 +774,8 @@ async function openCreateForm() { errBox.classList.remove('hidden'); } } + + if (currentAccount?.default_repo) loadCreateRepoLabels(); } function updateRepoHint() { diff --git a/public/plugins/tests/asset-picker.test.js b/public/plugins/tests/asset-picker.test.js new file mode 100644 index 0000000..207030a --- /dev/null +++ b/public/plugins/tests/asset-picker.test.js @@ -0,0 +1,63 @@ +import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll } from './runner.js?v=4'; + +async function openEditor() { + const card = document.querySelector('#board [data-id]'); + if (!card) return false; + card.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + for (let i = 0; i < 20; i++) { + if ($('#modal')?.classList.contains('open')) return true; + await sleep(100); + } + return false; +} + +async function openPicker() { + click('#md-asset-picker'); + for (let i = 0; i < 30; i++) { + if (document.getElementById('asset-picker-flyout')) return true; + await sleep(100); + } + return false; +} + +describe('Asset Picker', () => { + afterEach(async () => { + document.getElementById('asset-picker-flyout')?.remove(); + await closeAll(); + }); + + it('toolbar button exists', () => { + expect($('#md-asset-picker')).toExist(); + }); + + it('opens flyout with search and grid, toggles closed on second click', async () => { + if (!await openEditor()) return; + if (!await openPicker()) return; + + expect(document.getElementById('asset-picker-flyout')).toExist(); + expect(document.getElementById('asset-picker-search')).toExist(); + expect(document.getElementById('asset-picker-grid')).toExist(); + + // Second click closes + click('#md-asset-picker'); + for (let i = 0; i < 20; i++) { + if (!document.getElementById('asset-picker-flyout')) break; + await sleep(50); + } + expect(document.getElementById('asset-picker-flyout')).toBe(null); + }); + + it('search input filters tiles', async () => { + if (!await openEditor()) return; + if (!await openPicker()) return; + + const search = document.getElementById('asset-picker-search'); + if (!search) return; + + search.value = 'zzz_nonexistent_zzz'; + search.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(50); + const grid = document.getElementById('asset-picker-grid'); + expect(grid.querySelectorAll('.asset-pick-tile').length).toBe(0); + }); +}); diff --git a/public/plugins/tests/runner.js b/public/plugins/tests/runner.js index 8c5b2f6..0108ec0 100644 --- a/public/plugins/tests/runner.js +++ b/public/plugins/tests/runner.js @@ -180,6 +180,7 @@ export async function closeAll() { document.querySelector('.bucket-picker')?.remove(); document.querySelector('.orphan-lightbox')?.remove(); document.getElementById('bucket-super-popover')?.remove(); + document.getElementById('asset-picker-flyout')?.remove(); document.getElementById('gitea-overlay')?.classList.remove('open'); // Clear multi-select document.querySelectorAll('.card-selected').forEach(el => { diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index b921fab..a5aca57 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -50,6 +50,7 @@ const FILES = [ './relay.test.js', './notifications.test.js', './quick-capture.test.js', + './asset-picker.test.js', './theme.test.js', ];