Upload full Albumik YunoHost package

This commit is contained in:
adminsopel
2026-05-01 09:06:44 +02:00
parent 2145989988
commit f8beabe293
16 changed files with 1690 additions and 0 deletions

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
scripts/* text eol=lf
*.sh text eol=lf
*.toml text eol=lf
*.py text eol=lf

1069
backend/app.py Executable file

File diff suppressed because it is too large Load Diff

20
conf/albumik.service Normal file
View File

@@ -0,0 +1,20 @@
[Unit]
Description=Albumik private photo album
After=network.target
[Service]
Type=simple
User=__APP__
Group=__APP__
WorkingDirectory=__INSTALL_DIR__
Environment=ALBUMIK_CONFIG=/etc/__APP__/config.json
ExecStart=/usr/bin/python3 __INSTALL_DIR__/backend/app.py
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=__DATA_DIR__
[Install]
WantedBy=multi-user.target

13
conf/config.json Normal file
View File

@@ -0,0 +1,13 @@
{
"app_name": "Albumik",
"listen": "127.0.0.1:__PORT__",
"base_path": "__PATH__",
"data_dir": "__DATA_DIR__",
"database": "__DATA_DIR__/data/albumik.db",
"media_dir": "__DATA_DIR__/media",
"web_dir": "__INSTALL_DIR__/web",
"admin_user": "__ADMIN_USER__",
"admin_password": "__ADMIN_PASSWORD__",
"session_days": 14,
"max_upload_mb": 2048
}

11
conf/nginx.conf Normal file
View File

@@ -0,0 +1,11 @@
# Albumik - YunoHost nginx reverse proxy
location __PATH__/ {
proxy_pass http://127.0.0.1:__PORT__/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_send_timeout 300;
client_max_body_size 2048M;
}

32
scripts/_common.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
app=${YNH_APP_INSTANCE_NAME:-albumik}
install_dir="/var/www/$app"
data_dir="/home/yunohost.app/$app"
config_dir="/etc/$app"
service_file="/etc/systemd/system/$app.service"
find_free_port() {
python3 - <<'PY'
import socket
for p in range(8097, 8297):
s=socket.socket()
try:
s.bind(('127.0.0.1', p))
print(p)
break
except OSError:
pass
finally:
s.close()
PY
}
normalize_path() {
local p="$1"
[ -z "$p" ] && p="/"
[[ "$p" != /* ]] && p="/$p"
[ "$p" != "/" ] && p="${p%/}"
echo "$p"
}

0
backup → scripts/backup Normal file → Executable file
View File

65
scripts/install Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
set -euo pipefail
source ./scripts/_common.sh
# YunoHost install args
# packaging v2 passes these variables to scripts.
domain=${YNH_APP_ARG_DOMAIN:?Missing domain}
path=$(normalize_path "${YNH_APP_ARG_PATH:-/}")
admin_user=${YNH_APP_ARG_ADMIN_USER:-${YNH_APP_ARG_ADMIN:-admin}}
admin_password=${YNH_APP_ARG_ADMIN_PASSWORD:-${YNH_APP_ARG_PASSWORD:-}}
if [ -z "$admin_password" ]; then
echo "Admin password is required" >&2
exit 1
fi
port=$(find_free_port)
# Create dedicated app user when resources.system_user did not already do it.
if ! id "$app" >/dev/null 2>&1; then
useradd --system --home "$install_dir" --shell /usr/sbin/nologin "$app"
fi
mkdir -p "$install_dir" "$data_dir/data" "$data_dir/media/originals" "$data_dir/media/thumbs" "$data_dir/media/previews" "$data_dir/media/pending" "$data_dir/logs" "$config_dir"
cp -a backend web doc "$install_dir"/
cp conf/config.json "$config_dir/config.json"
sed -i \
-e "s#__PORT__#$port#g" \
-e "s#__PATH__#$path#g" \
-e "s#__DATA_DIR__#$data_dir#g" \
-e "s#__INSTALL_DIR__#$install_dir#g" \
-e "s#__ADMIN_USER__#$admin_user#g" \
-e "s#__ADMIN_PASSWORD__#$admin_password#g" \
"$config_dir/config.json"
cp conf/albumik.service "$service_file"
sed -i \
-e "s#__APP__#$app#g" \
-e "s#__DATA_DIR__#$data_dir#g" \
-e "s#__INSTALL_DIR__#$install_dir#g" \
"$service_file"
mkdir -p "/etc/nginx/conf.d/$domain.d"
cp conf/nginx.conf "/etc/nginx/conf.d/$domain.d/$app.conf"
sed -i \
-e "s#__PORT__#$port#g" \
-e "s#__PATH__#$path#g" \
"/etc/nginx/conf.d/$domain.d/$app.conf"
chown -R "$app:$app" "$install_dir" "$data_dir" "$config_dir"
chmod 750 "$data_dir" "$config_dir"
chmod 640 "$config_dir/config.json"
# Save a few settings for remove/backup scripts if yunohost helpers are available.
if command -v yunohost >/dev/null 2>&1; then
yunohost app setting "$app" domain -v "$domain" || true
yunohost app setting "$app" path -v "$path" || true
yunohost app setting "$app" port -v "$port" || true
fi
systemctl daemon-reload
systemctl enable --now "$app"
nginx -t
systemctl reload nginx
echo "Albumik installed: https://$domain$path"

20
scripts/remove Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -euo pipefail
source ./scripts/_common.sh
domain=$(yunohost app setting "$app" domain 2>/dev/null || echo "")
systemctl disable --now "$app" 2>/dev/null || true
rm -f "$service_file"
systemctl daemon-reload || true
if [ -n "$domain" ]; then
rm -f "/etc/nginx/conf.d/$domain.d/$app.conf"
nginx -t && systemctl reload nginx || true
fi
rm -rf "$install_dir" "$config_dir"
# Dane użytkownika zostawiamy bezpiecznie. Usuń ręcznie, jeśli naprawdę chcesz:
# rm -rf "$data_dir"
echo "Albumik removed. Data left in $data_dir"

18
scripts/restore Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -euo pipefail
source ./scripts/_common.sh
if [ -f "$YNH_BACKUP_DIR/albumik-data.tar.gz" ]; then
tar -C / -xzf "$YNH_BACKUP_DIR/albumik-data.tar.gz"
fi
if [ -f "$YNH_BACKUP_DIR/$app.service" ]; then
cp "$YNH_BACKUP_DIR/$app.service" "$service_file"
fi
if ! id "$app" >/dev/null 2>&1; then
useradd --system --home "$install_dir" --shell /usr/sbin/nologin "$app"
fi
chown -R "$app:$app" "$install_dir" "$data_dir" "$config_dir" 2>/dev/null || true
systemctl daemon-reload
systemctl enable --now "$app"
systemctl reload nginx || true
echo "Albumik restored"

13
scripts/upgrade Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -euo pipefail
source ./scripts/_common.sh
systemctl stop "$app" 2>/dev/null || true
mkdir -p "$install_dir"
rm -rf "$install_dir/backend" "$install_dir/web" "$install_dir/doc"
cp -a backend web doc "$install_dir"/
chown -R "$app:$app" "$install_dir"
systemctl daemon-reload
systemctl start "$app"
systemctl reload nginx || true
echo "Albumik upgraded"

223
web/app.js Normal file
View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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
View 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

File diff suppressed because one or more lines are too long