gitea: default repo setting; editor: asset picker with thumbnail grid
Gitea plugin: default_repo field per account (stored in DB, pre-fills create issue form but stays editable). Editor toolbar: asset picker flyout with 4-column thumbnail grid and live search filter. Clicking a tile inserts the matching markdown. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c5bf8b0407
commit
04b52ecc19
9 files changed
+197
-6
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.1.0');
|
||||
define('APP_VERSION', '1.2.0');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -2203,6 +2203,7 @@ if (str_starts_with($route, '/api/')) {
|
||||
<button data-md="extract" title="Extract selection to new card" class="toolbar-btn"><i class="ph ph-scissors text-sm"></i></button>
|
||||
<button id="md-upload-pdf" title="Upload & link PDF" class="toolbar-btn"><i class="ph ph-file-pdf text-sm"></i></button>
|
||||
<input type="file" id="md-upload-pdf-file" accept=".pdf,application/pdf" class="hidden">
|
||||
<button id="md-asset-picker" title="Insert from assets" class="toolbar-btn" style="position:relative;"><i class="ph ph-images text-sm"></i></button>
|
||||
<div class="w-px h-4 bg-t-border mx-1"></div>
|
||||
<button id="md-find-replace" title="Find & replace (Ctrl+H)" class="toolbar-btn" onclick="openFindBar(true)"><i class="ph ph-magnifying-glass text-sm"></i></button>
|
||||
<?= PluginManager::instance()->slot('md-toolbar') ?>
|
||||
|
||||
@@ -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 = `<div style="padding:8px;border-bottom:1px solid var(--t-border);">
|
||||
<input id="asset-picker-search" type="text" placeholder="search assets..." autocomplete="off"
|
||||
style="width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:5px 8px;font-size:11px;font-family:var(--t-font-content,'JetBrains Mono',monospace);border-radius:4px;outline:none;"
|
||||
class="focus:border-t-accent">
|
||||
</div>`;
|
||||
|
||||
const gridId = 'asset-picker-grid';
|
||||
picker.innerHTML = searchHtml + `<div id="${gridId}" style="flex:1;overflow-y:auto;padding:8px;display:grid;grid-template-columns:repeat(4,1fr);gap:6px;"></div>`;
|
||||
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 = `<div style="grid-column:1/-1;text-align:center;padding:24px 0;color:var(--t-dim);font-size:10px;font-style:italic;">${files.length ? 'no matches' : 'no assets'}</div>`;
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = filtered.map(f => {
|
||||
const type = assetType(f.name);
|
||||
const label = f.name.replace(/\.[^.]+$/, '');
|
||||
if (type === 'image') {
|
||||
return `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="image" title="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);">
|
||||
<img src="${esc(f.url)}" style="width:100%;height:100%;object-fit:cover;" loading="lazy">
|
||||
</div>`;
|
||||
}
|
||||
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 `<div class="asset-pick-tile" data-url="${esc(f.url)}" data-name="${esc(f.name)}" data-type="${type}" title="${esc(f.name)}" style="cursor:pointer;border-radius:4px;overflow:hidden;aspect-ratio:1;background:var(--t-bg);border:1px solid var(--t-border);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:4px;">
|
||||
<i class="ph ${icon}" style="font-size:20px;${iconColor}"></i>
|
||||
<span style="font-size:7px;color:var(--t-muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--t-font-pixel,'Silkscreen',monospace);">${esc(label)}</span>
|
||||
</div>`;
|
||||
}).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 = `<audio controls src="${url}"></audio>`;
|
||||
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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -113,6 +113,10 @@ function buildSettingsPanel(panelEl) {
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">access token <span id="gitea-tok-hint" class="text-t-muted normal-case text-[8px]"></span></span>
|
||||
<input id="gitea-acc-token" type="password" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="font-pixel text-[9px] text-t-dim uppercase">default repo <span class="text-t-muted normal-case text-[8px]">(owner/repo)</span></span>
|
||||
<input id="gitea-acc-default-repo" type="text" placeholder="owner/repo" autocomplete="off" class="w-full bg-t-bg border border-t-border text-t-bright px-2 py-1.5 text-xs focus:border-t-accent focus:outline-none font-mono">
|
||||
</label>
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button type="submit" id="gitea-acc-save" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10 transition-colors"><i class="ph ph-floppy-disk text-sm"></i><span class="font-pixel text-[10px]">Save</span></button>
|
||||
<button type="button" id="gitea-acc-test" class="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors"><i class="ph ph-plug text-sm"></i><span class="font-pixel text-[10px]">Test</span></button>
|
||||
@@ -150,7 +154,7 @@ function renderAccountsList() {
|
||||
<i class="ph ph-git-pull-request text-t-accent text-base"></i>
|
||||
<div class="gitea-account-info">
|
||||
<div class="gitea-account-name">${escapeHtml(a.name)}</div>
|
||||
<div class="gitea-account-meta">${escapeHtml(a.base_url)}</div>
|
||||
<div class="gitea-account-meta">${escapeHtml(a.base_url)}${a.default_repo ? ' · ' + escapeHtml(a.default_repo) : ''}</div>
|
||||
</div>
|
||||
<div class="gitea-account-actions">
|
||||
<button class="toolbar-btn gitea-edit-btn" title="Edit"><i class="ph ph-pencil-simple text-sm"></i></button>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
@@ -50,6 +50,7 @@ const FILES = [
|
||||
'./relay.test.js',
|
||||
'./notifications.test.js',
|
||||
'./quick-capture.test.js',
|
||||
'./asset-picker.test.js',
|
||||
'./theme.test.js',
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user