224 lines
13 KiB
JavaScript
224 lines
13 KiB
JavaScript
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();
|