Upload full Albumik YunoHost package
This commit is contained in:
223
web/app.js
Normal file
223
web/app.js
Normal file
@@ -0,0 +1,223 @@
|
||||
const $ = (q) => document.querySelector(q);
|
||||
const BASE_PATH = (() => { const p = window.location.pathname; if (p === "/" || p === "") return ""; return p.endsWith("/") ? p.slice(0, -1) : p.replace(/\/[^/]*$/, ""); })();
|
||||
const U = (path) => (BASE_PATH || "") + path;
|
||||
const $$ = (q) => Array.from(document.querySelectorAll(q));
|
||||
const state = {
|
||||
user: null,
|
||||
folders: [],
|
||||
tags: [],
|
||||
selectedFolder: '',
|
||||
currentView: 'photos',
|
||||
currentPhoto: null,
|
||||
};
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await {fetch(U(path), {
|
||||
credentials: 'same-origin',
|
||||
headers: opts.body instanceof FormData ? {} : {'Content-Type':'application/json'},
|
||||
...opts,
|
||||
});
|
||||
const type = res.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await res.json() : await res.text();
|
||||
if (!res.ok || (data && data.ok === false)) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
function fmtBytes(n){ if(!n) return '0 MB'; const u=['B','KB','MB','GB','TB']; let i=0; while(n>1024&&i<u.length-1){n/=1024;i++} return `${n.toFixed(i?1:0)} ${u[i]}` }
|
||||
function esc(s){ return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])) }
|
||||
function roleName(r){ return r==='admin'?'Admin':r==='user'?'Użytkownik':'Gość' }
|
||||
function canAdmin(){ return state.user?.role === 'admin' }
|
||||
function visibleAdmin(){ $$('.admin-only').forEach(x=>x.classList.toggle('hidden-role', !canAdmin())) }
|
||||
|
||||
async function init(){
|
||||
try {
|
||||
const me = await api('/api/me');
|
||||
state.user = me.user;
|
||||
$('#login').classList.add('hidden');
|
||||
$('#app').classList.remove('hidden');
|
||||
$('#currentUser').innerHTML = `<strong>${esc(state.user.display_name)}</strong><br><span>${roleName(state.user.role)}</span>`;
|
||||
visibleAdmin();
|
||||
await reloadAll();
|
||||
} catch(e) {
|
||||
$('#login').classList.remove('hidden');
|
||||
$('#app').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
async function reloadAll(){
|
||||
await Promise.all([loadFolders(), loadTags(), loadStats()]);
|
||||
await loadPhotos();
|
||||
if(canAdmin()) { loadUsers(); loadEvents(); }
|
||||
}
|
||||
async function loadStats(){
|
||||
const r = await api('/api/stats');
|
||||
$('#statPhotos').textContent = r.stats.photos ?? 0;
|
||||
$('#statPending').textContent = r.stats.pending ?? 0;
|
||||
$('#pendingBubble').textContent = r.stats.pending ?? 0;
|
||||
$('#statFolders').textContent = r.stats.folders ?? 0;
|
||||
$('#statBytes').textContent = fmtBytes(r.stats.bytes ?? 0);
|
||||
}
|
||||
async function loadFolders(){
|
||||
const r = await api('/api/folders');
|
||||
state.folders = r.folders || [];
|
||||
renderFolderTrees();
|
||||
fillFolderSelects();
|
||||
}
|
||||
async function loadTags(){
|
||||
const r = await api('/api/tags');
|
||||
state.tags = r.tags || [];
|
||||
const opt = ['<option value="">Wszystkie tagi</option>'].concat(state.tags.map(t=>`<option value="${esc(t.name)}">#${esc(t.name)}</option>`));
|
||||
$('#tagFilter').innerHTML = opt.join('');
|
||||
$('#tagCloud').innerHTML = state.tags.length ? state.tags.map(t=>`<button style="background:${esc(t.color)}" data-tag="${esc(t.name)}">#${esc(t.name)} <small>${t.count||0}</small></button>`).join('') : '<div class="empty">Brak tagów</div>';
|
||||
$('#quickTags').innerHTML = state.tags.slice(0,10).map(t=>`<button style="background:${esc(t.color)}" data-tag="${esc(t.name)}">#${esc(t.name)}</button>`).join('') || '<span class="hint">Jeszcze brak tagów</span>';
|
||||
}
|
||||
function depth(path){ return Math.max(0, (path || '').split('/').filter(Boolean).length - 1) }
|
||||
function renderFolderTrees(){
|
||||
const rows = state.folders.map(f => {
|
||||
const d = depth(f.path);
|
||||
const selected = state.selectedFolder === f.id ? 'selected' : '';
|
||||
return `<div class="folder-row ${selected}" data-folder="${esc(f.id)}" style="padding-left:${10+d*18}px"><span>${esc(f.name)}</span><small>${esc(f.permission)}</small></div>`;
|
||||
}).join('') || '<div class="empty">Brak katalogów</div>';
|
||||
$('#folderTree').innerHTML = rows;
|
||||
$('#miniFolderTree').innerHTML = rows;
|
||||
}
|
||||
function fillFolderSelects(){
|
||||
const opts = ['<option value="">Bez nadrzędnego</option>'].concat(state.folders.map(f=>`<option value="${esc(f.id)}">${'— '.repeat(depth(f.path))}${esc(f.name)}</option>`));
|
||||
$('#folderParentSelect').innerHTML = opts.join('');
|
||||
const normal = state.folders.map(f=>`<option value="${esc(f.id)}">${'— '.repeat(depth(f.path))}${esc(f.path)}</option>`).join('');
|
||||
$('#uploadFolderSelect').innerHTML = normal;
|
||||
$('#moveFolderSelect').innerHTML = normal;
|
||||
if(state.selectedFolder) $('#uploadFolderSelect').value = state.selectedFolder;
|
||||
}
|
||||
async function loadPhotos(statusOverride){
|
||||
const params = new URLSearchParams();
|
||||
const q = $('#searchInput').value.trim();
|
||||
const tag = $('#tagFilter').value;
|
||||
const status = statusOverride || $('#statusFilter').value || 'approved';
|
||||
if(state.selectedFolder) params.set('folder_id', state.selectedFolder);
|
||||
if(q) params.set('q', q);
|
||||
if(tag) params.set('tag', tag);
|
||||
if($('#dateFrom').value) params.set('date_from', $('#dateFrom').value);
|
||||
if($('#dateTo').value) params.set('date_to', $('#dateTo').value);
|
||||
params.set('status', status);
|
||||
const r = await api('/api/photos?' + params.toString());
|
||||
const grid = statusOverride === 'pending' ? $('#pendingGrid') : $('#photoGrid');
|
||||
renderPhotos(grid, r.photos || []);
|
||||
const folder = state.folders.find(f=>f.id===state.selectedFolder);
|
||||
$('#folderBreadcrumb').textContent = folder ? folder.path : 'Wszystkie dostępne katalogi';
|
||||
}
|
||||
function renderPhotos(grid, photos){
|
||||
if(!photos.length){ grid.innerHTML = '<div class="empty">Brak zdjęć do pokazania</div>'; return; }
|
||||
grid.innerHTML = photos.map(p => {
|
||||
const isVid = p.is_video ? '<span class="video-badge">WIDEO</span>' : '';
|
||||
const pending = p.status === 'pending' ? '<span class="status-badge pending">OCZEKUJE</span>' : '';
|
||||
const tags = (p.tags || []).map(t=>`<span class="tag-pill" style="background:${esc(t.color)}">#${esc(t.name)}</span>`).join('');
|
||||
const img = p.is_video ? `<div class="empty">Film</div>` : `<img src="${esc(p.url)}" loading="lazy" alt="${esc(p.file_name)}" />`;
|
||||
return `<article class="photo-card" data-photo='${esc(JSON.stringify(p))}'>
|
||||
<div class="photo-thumb">${img}${isVid}${pending}</div>
|
||||
<div class="photo-body"><div class="photo-title">${esc(p.file_name)}</div><div class="photo-path">${esc(p.folder_path)}</div><div class="photo-tags">${tags}</div></div>
|
||||
</article>`;
|
||||
}).join('');
|
||||
}
|
||||
function switchView(name){
|
||||
state.currentView = name;
|
||||
$$('.nav-item').forEach(b=>b.classList.toggle('active', b.dataset.view===name));
|
||||
$$('.view').forEach(v=>v.classList.remove('active-view'));
|
||||
$(`#view${name[0].toUpperCase()+name.slice(1)}`).classList.add('active-view');
|
||||
if(name==='pending') loadPhotos('pending');
|
||||
if(name==='logs') loadEvents();
|
||||
if(name==='users') loadUsers();
|
||||
}
|
||||
async function loadUsers(){
|
||||
if(!canAdmin()) return;
|
||||
const r = await api('/api/users');
|
||||
const rows = (r.users||[]).map(u=>`<tr><td><strong>${esc(u.display_name)}</strong><br><small>${esc(u.username)}</small></td><td><span class="role-badge">${roleName(u.role)}</span></td><td>${u.can_create_folders?'katalogi ':''}${u.can_upload?'upload ':''}${u.can_tag?'tagi ':''}</td><td>${u.active?'Aktywny':'Wyłączony'}</td></tr>`).join('');
|
||||
$('#usersTable').innerHTML = `<table><thead><tr><th>Konto</th><th>Rola</th><th>Może</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
async function loadEvents(){
|
||||
if(!canAdmin()) return;
|
||||
const r = await api('/api/events');
|
||||
const html = (r.events||[]).map(e=>`<div class="log-item"><strong>${esc(e.message)}</strong><small>${esc(e.created_at)} • ${esc(e.actor_name||'system')} • ${esc(e.action)}</small></div>`).join('') || '<div class="empty">Brak zdarzeń</div>';
|
||||
$('#logsList').innerHTML = html;
|
||||
$('#activityMini').innerHTML = (r.events||[]).slice(0,4).map(e=>`<div class="hint">${esc(e.message)}</div>`).join('') || 'Brak danych';
|
||||
}
|
||||
function openPhoto(p){
|
||||
state.currentPhoto = p;
|
||||
$('#photoPreview').src = p.is_video ? '' : p.url;
|
||||
$('#photoTitle').textContent = p.file_name;
|
||||
$('#photoMeta').innerHTML = `${esc(p.folder_path)}<br>${fmtBytes(p.size_bytes)} • ${esc(p.status)}<br>Wysłał: ${esc(p.submitted_by_name || '')}`;
|
||||
$('#photoTagsInput').value = (p.tags||[]).map(t=>t.name).join(', ');
|
||||
$('#moveFolderSelect').value = p.folder_id;
|
||||
$('#approvePhotoBtn').style.display = p.status === 'pending' ? '' : 'none';
|
||||
$('#rejectPhotoBtn').style.display = p.status === 'pending' ? '' : 'none';
|
||||
$('#photoDialog').showModal();
|
||||
}
|
||||
|
||||
$('#loginForm').addEventListener('submit', async e=>{
|
||||
e.preventDefault();
|
||||
$('#loginError').textContent = '';
|
||||
const fd = new FormData(e.currentTarget);
|
||||
try { await api('/api/login', {method:'POST', body: JSON.stringify(Object.fromEntries(fd))}); await init(); }
|
||||
catch(err){ $('#loginError').textContent = err.message; }
|
||||
});
|
||||
$('#logoutBtn').addEventListener('click', async()=>{ await api('/api/logout',{method:'POST',body:'{}'}); location.reload(); });
|
||||
$$('.nav-item').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view)));
|
||||
$('#refreshBtn').addEventListener('click', reloadAll);
|
||||
['searchInput','dateFrom','dateTo','tagFilter','statusFilter'].forEach(id=>$("#"+id).addEventListener('change',()=>loadPhotos()));
|
||||
$('#searchInput').addEventListener('input', debounce(()=>loadPhotos(), 350));
|
||||
function debounce(fn, ms){ let t; return (...a)=>{ clearTimeout(t); t=setTimeout(()=>fn(...a),ms) } }
|
||||
|
||||
document.body.addEventListener('click', e=>{
|
||||
const close = e.target.dataset.close; if(close) $('#'+close).close();
|
||||
const nav = e.target.closest('[data-folder]'); if(nav){ state.selectedFolder = nav.dataset.folder; renderFolderTrees(); loadPhotos(); fillFolderSelects(); }
|
||||
const tagBtn = e.target.closest('[data-tag]'); if(tagBtn){ $('#tagFilter').value = tagBtn.dataset.tag; switchView('photos'); loadPhotos(); }
|
||||
const card = e.target.closest('.photo-card'); if(card){ openPhoto(JSON.parse(card.dataset.photo)); }
|
||||
});
|
||||
$('#uploadOpenBtn').addEventListener('click',()=>$('#uploadDialog').showModal());
|
||||
$('#newFolderBtn').addEventListener('click',()=>$('#folderDialog').showModal());
|
||||
$('#newFolderBtn2').addEventListener('click',()=>$('#folderDialog').showModal());
|
||||
$('#newUserBtn').addEventListener('click',()=>$('#userDialog').showModal());
|
||||
$('#newTagBtn').addEventListener('click', async()=>{ const name=prompt('Nazwa tagu'); if(name){ await api('/api/tags',{method:'POST',body:JSON.stringify({name})}); await loadTags(); } });
|
||||
|
||||
$('#folderForm').addEventListener('submit', async e=>{
|
||||
e.preventDefault(); const fd = new FormData(e.currentTarget);
|
||||
await api('/api/folders',{method:'POST',body:JSON.stringify({name:fd.get('name'), parent_id:fd.get('parent_id')||null, inherit_permissions:!!fd.get('inherit_permissions')})});
|
||||
$('#folderDialog').close(); e.currentTarget.reset(); await loadFolders(); await loadStats();
|
||||
});
|
||||
$('#uploadForm').addEventListener('submit', async e=>{
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
$('#uploadStatus').textContent = 'Wysyłam...';
|
||||
try { await api('/api/photos/upload',{method:'POST',body:fd}); $('#uploadStatus').textContent='Wysłane.'; setTimeout(()=>$('#uploadDialog').close(),500); await reloadAll(); }
|
||||
catch(err){ $('#uploadStatus').textContent = err.message; }
|
||||
});
|
||||
$('#userForm').addEventListener('submit', async e=>{
|
||||
e.preventDefault(); const fd = new FormData(e.currentTarget);
|
||||
const payload = Object.fromEntries(fd); payload.can_create_folders=!!fd.get('can_create_folders'); payload.can_upload=!!fd.get('can_upload'); payload.can_tag=!!fd.get('can_tag'); if(!payload.expires_at) delete payload.expires_at;
|
||||
const r = await api('/api/users',{method:'POST',body:JSON.stringify(payload)});
|
||||
$('#userCreateResult').textContent = `Utworzono. Hasło: ${r.password}`;
|
||||
await loadUsers();
|
||||
});
|
||||
$('#saveTagsBtn').addEventListener('click', async()=>{
|
||||
const p = state.currentPhoto; if(!p) return;
|
||||
const tags = $('#photoTagsInput').value.split(',').map(x=>x.trim()).filter(Boolean);
|
||||
await api(`/api/photos/${p.id}/tags`,{method:'POST',body:JSON.stringify({tags})});
|
||||
$('#photoDialog').close(); await loadTags(); await loadPhotos(state.currentView==='pending'?'pending':undefined);
|
||||
});
|
||||
$('#movePhotoBtn').addEventListener('click', async()=>{
|
||||
const p = state.currentPhoto; if(!p) return;
|
||||
await api(`/api/photos/${p.id}/move`,{method:'POST',body:JSON.stringify({folder_id:$('#moveFolderSelect').value})});
|
||||
$('#photoDialog').close(); await loadPhotos(state.currentView==='pending'?'pending':undefined);
|
||||
});
|
||||
$('#approvePhotoBtn').addEventListener('click', async()=>{
|
||||
const p = state.currentPhoto; if(!p) return;
|
||||
const tags = $('#photoTagsInput').value.split(',').map(x=>x.trim()).filter(Boolean);
|
||||
await api(`/api/photos/${p.id}/approve`,{method:'POST',body:JSON.stringify({folder_id:$('#moveFolderSelect').value,tags})});
|
||||
$('#photoDialog').close(); await reloadAll(); if(state.currentView==='pending') loadPhotos('pending');
|
||||
});
|
||||
$('#rejectPhotoBtn').addEventListener('click', async()=>{
|
||||
const p = state.currentPhoto; if(!p) return;
|
||||
const reason = prompt('Powód odrzucenia, opcjonalnie') || '';
|
||||
await api(`/api/photos/${p.id}/reject`,{method:'POST',body:JSON.stringify({reason})});
|
||||
$('#photoDialog').close(); await reloadAll(); if(state.currentView==='pending') loadPhotos('pending');
|
||||
});
|
||||
|
||||
init();
|
||||
195
web/index.html
Normal file
195
web/index.html
Normal file
@@ -0,0 +1,195 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Albumik</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="login-shell hidden">
|
||||
<div class="login-card">
|
||||
<div class="brand-badge">A</div>
|
||||
<h1>Albumik</h1>
|
||||
<p>Lekki prywatny album zdjęć na Twoim serwerze.</p>
|
||||
<form id="loginForm">
|
||||
<label>Login</label>
|
||||
<input name="username" autocomplete="username" placeholder="admin" required />
|
||||
<label>Hasło</label>
|
||||
<input name="password" type="password" autocomplete="current-password" placeholder="Hasło" required />
|
||||
<button type="submit">Zaloguj</button>
|
||||
<div id="loginError" class="error-line"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" class="app hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="logo-row">
|
||||
<div class="logo-mark">A</div>
|
||||
<div>
|
||||
<div class="logo-title">Albumik</div>
|
||||
<div class="logo-subtitle">foto.serwer</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<button class="nav-item active" data-view="photos"><span>Galeria</span></button>
|
||||
<button class="nav-item" data-view="pending"><span>Do akceptacji</span><em id="pendingBubble">0</em></button>
|
||||
<button class="nav-item" data-view="folders"><span>Katalogi</span></button>
|
||||
<button class="nav-item" data-view="tags"><span>Tagi</span></button>
|
||||
<button class="nav-item admin-only" data-view="users"><span>Użytkownicy</span></button>
|
||||
<button class="nav-item admin-only" data-view="logs"><span>Dziennik</span></button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div id="currentUser" class="current-user"></div>
|
||||
<button id="logoutBtn" class="ghost-btn">Wyloguj</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="searchbox">
|
||||
<span>⌕</span>
|
||||
<input id="searchInput" placeholder="Szukaj po nazwie, katalogu, tagu..." />
|
||||
</div>
|
||||
<div class="filters compact">
|
||||
<input id="dateFrom" type="date" title="Od daty" />
|
||||
<input id="dateTo" type="date" title="Do daty" />
|
||||
<select id="tagFilter"><option value="">Wszystkie tagi</option></select>
|
||||
<select id="statusFilter"><option value="approved">Zaakceptowane</option><option value="pending">Do akceptacji</option><option value="all">Wszystkie</option></select>
|
||||
</div>
|
||||
<button id="uploadOpenBtn" class="primary-btn">Dodaj zdjęcia</button>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<div class="stat-card"><span>Zdjęcia</span><strong id="statPhotos">0</strong><small>zaakceptowane</small></div>
|
||||
<div class="stat-card"><span>Do akceptacji</span><strong id="statPending">0</strong><small>od gości</small></div>
|
||||
<div class="stat-card"><span>Katalogi</span><strong id="statFolders">0</strong><small>z dostępem</small></div>
|
||||
<div class="stat-card"><span>Zajęte miejsce</span><strong id="statBytes">0 MB</strong><small>oryginały</small></div>
|
||||
</section>
|
||||
|
||||
<section id="viewPhotos" class="view active-view">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>Zdjęcia</h2>
|
||||
<p id="folderBreadcrumb">Wszystkie dostępne katalogi</p>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="newFolderBtn" class="soft-btn">Nowy katalog</button>
|
||||
<button id="refreshBtn" class="soft-btn">Odśwież</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="photoGrid" class="photo-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="viewPending" class="view">
|
||||
<div class="section-head">
|
||||
<div><h2>Zdjęcia do akceptacji</h2><p>Goście wrzucają, admin lub uprawniony użytkownik zatwierdza.</p></div>
|
||||
</div>
|
||||
<div id="pendingGrid" class="photo-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="viewFolders" class="view">
|
||||
<div class="section-head">
|
||||
<div><h2>Katalogi i podkatalogi</h2><p>Drzewo katalogów z głęboką strukturą.</p></div>
|
||||
<button id="newFolderBtn2" class="primary-btn">Nowy katalog</button>
|
||||
</div>
|
||||
<div class="panel-card"><div id="folderTree" class="folder-tree"></div></div>
|
||||
</section>
|
||||
|
||||
<section id="viewTags" class="view">
|
||||
<div class="section-head"><div><h2>Tagi</h2><p>Szybkie tagowanie i filtrowanie zdjęć.</p></div><button id="newTagBtn" class="primary-btn">Nowy tag</button></div>
|
||||
<div id="tagCloud" class="tag-cloud panel-card"></div>
|
||||
</section>
|
||||
|
||||
<section id="viewUsers" class="view admin-only">
|
||||
<div class="section-head"><div><h2>Użytkownicy i goście</h2><p>Admin, użytkownicy oraz goście z dostępem do wybranych katalogów.</p></div><button id="newUserBtn" class="primary-btn">Nowe konto</button></div>
|
||||
<div id="usersTable" class="panel-card table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="viewLogs" class="view admin-only">
|
||||
<div class="section-head"><div><h2>Dziennik zdarzeń</h2><p>Kto, co i kiedy zrobił.</p></div></div>
|
||||
<div id="logsList" class="panel-card logs-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="rightbar">
|
||||
<div class="side-card">
|
||||
<h3>Katalogi</h3>
|
||||
<div id="miniFolderTree" class="mini-tree"></div>
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<h3>Szybkie tagi</h3>
|
||||
<div id="quickTags" class="quick-tags"></div>
|
||||
</div>
|
||||
<div class="side-card">
|
||||
<h3>Aktywność</h3>
|
||||
<div id="activityMini" class="activity-mini">Brak danych</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<dialog id="uploadDialog" class="modal">
|
||||
<form id="uploadForm" method="dialog">
|
||||
<h3>Dodaj zdjęcia / filmy</h3>
|
||||
<label>Katalog</label>
|
||||
<select name="folder_id" id="uploadFolderSelect" required></select>
|
||||
<label>Tagi, po przecinku</label>
|
||||
<input name="tags" placeholder="wakacje, rodzina, morze" />
|
||||
<label>Data utworzenia / wykonania</label>
|
||||
<input name="taken_at" type="datetime-local" />
|
||||
<label>Pliki</label>
|
||||
<input name="file" type="file" multiple accept="image/*,video/*" required />
|
||||
<div class="modal-actions"><button type="button" class="ghost-btn" data-close="uploadDialog">Anuluj</button><button class="primary-btn" type="submit">Wyślij</button></div>
|
||||
<div id="uploadStatus" class="hint"></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="folderDialog" class="modal">
|
||||
<form id="folderForm" method="dialog">
|
||||
<h3>Nowy katalog / podkatalog</h3>
|
||||
<label>Nazwa</label><input name="name" placeholder="Lubiatowo 2024" required />
|
||||
<label>Katalog nadrzędny</label><select name="parent_id" id="folderParentSelect"></select>
|
||||
<label class="check"><input name="inherit_permissions" type="checkbox" checked /> Dziedzicz uprawnienia z nadrzędnego</label>
|
||||
<div class="modal-actions"><button type="button" class="ghost-btn" data-close="folderDialog">Anuluj</button><button class="primary-btn" type="submit">Utwórz</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="userDialog" class="modal">
|
||||
<form id="userForm" method="dialog">
|
||||
<h3>Nowe konto</h3>
|
||||
<label>Login</label><input name="username" required />
|
||||
<label>Nazwa wyświetlana</label><input name="display_name" />
|
||||
<label>Hasło</label><input name="password" placeholder="Puste = wygeneruj" />
|
||||
<label>Rola</label><select name="role"><option value="user">Użytkownik</option><option value="guest">Gość</option><option value="admin">Admin</option></select>
|
||||
<label class="check"><input name="can_create_folders" type="checkbox" /> Może tworzyć katalogi</label>
|
||||
<label class="check"><input name="can_upload" type="checkbox" /> Może wysyłać zdjęcia</label>
|
||||
<label class="check"><input name="can_tag" type="checkbox" /> Może tagować</label>
|
||||
<label>Konto ważne do</label><input name="expires_at" type="date" />
|
||||
<div class="modal-actions"><button type="button" class="ghost-btn" data-close="userDialog">Anuluj</button><button class="primary-btn" type="submit">Utwórz</button></div>
|
||||
<div id="userCreateResult" class="hint"></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="photoDialog" class="modal wide-modal">
|
||||
<div class="photo-detail">
|
||||
<div class="photo-preview-wrap"><img id="photoPreview" alt="Podgląd" /></div>
|
||||
<div class="photo-info">
|
||||
<h3 id="photoTitle">Zdjęcie</h3>
|
||||
<p id="photoMeta"></p>
|
||||
<label>Tagi</label><input id="photoTagsInput" placeholder="tag1, tag2" />
|
||||
<label>Przenieś do katalogu</label><select id="moveFolderSelect"></select>
|
||||
<div class="modal-actions stack-actions">
|
||||
<button id="saveTagsBtn" class="soft-btn">Zapisz tagi</button>
|
||||
<button id="movePhotoBtn" class="soft-btn">Przenieś</button>
|
||||
<button id="approvePhotoBtn" class="primary-btn">Akceptuj</button>
|
||||
<button id="rejectPhotoBtn" class="danger-btn">Odrzuć</button>
|
||||
<button type="button" class="ghost-btn" data-close="photoDialog">Zamknij</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
7
web/styles.css
Normal file
7
web/styles.css
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user