diff options
| author | Ken D'Ambrosio <ken@jots.org> | 2026-05-13 17:46:28 +0000 |
|---|---|---|
| committer | Ken D'Ambrosio <ken@jots.org> | 2026-05-13 17:46:28 +0000 |
| commit | 7950acb21b22e7bc6f10c50e1427850de2834b24 (patch) | |
| tree | 4502b913f9719e60d363a9d08b979fd9578207d1 | |
| parent | 63ee58aac2ab7b24eecbec791757cf6ecb5a2296 (diff) | |
Add folder rename and background update trigger to admin UI
- Admin edit form: "Folder name" field renames the directory on save;
also moves the thumbnail cache subtree to match the new path
- Admin edit page: "Run Update" button spawns update.rb in a background
thread, streams output into a terminal-style log panel via 1.5s polling;
shows Done/Error status when complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app.rb | 67 | ||||
| -rw-r--r-- | public/css/style.css | 13 | ||||
| -rw-r--r-- | views/admin/album.erb | 65 |
3 files changed, 145 insertions, 0 deletions
@@ -15,6 +15,9 @@ CACHE_ROOT = (ENV['CACHE_ROOT'] || '/opt/albumen/cache/thumbs').freeze CONFIG_PATH = (ENV['CONFIG_PATH'] || '/opt/albumen/config.yml').freeze THUMB_SIZE = 300 +UPDATE_JOBS = {} +UPDATE_JOBS_MUTEX = Mutex.new + IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze @@ -409,6 +412,28 @@ post '/admin/edit/*' do require_admin! rel = params[:splat].first.chomp('/') dir = resolve_dir(rel) + + unless rel.empty? + new_name = params['folder_name'].to_s.strip + new_name = '' if new_name.include?('/') || new_name.include?("\x00") || + new_name == '.' || new_name == '..' + old_name = File.basename(dir) + if !new_name.empty? && new_name != old_name + parent_dir = File.dirname(dir) + new_dir = File.join(parent_dir, new_name) + unless File.exist?(new_dir) + parent_rel = rel.include?('/') ? rel.split('/')[0..-2].join('/') : '' + new_rel = parent_rel.empty? ? new_name : "#{parent_rel}/#{new_name}" + old_cache = File.join(CACHE_ROOT, rel) + new_cache = File.join(CACHE_ROOT, new_rel) + FileUtils.mv(old_cache, new_cache) if File.directory?(old_cache) + FileUtils.mv(dir, new_dir) + save_edits(new_rel, new_dir) + redirect "/admin/edit/#{new_rel}" + end + end + end + save_edits(rel, dir) redirect "/admin/edit/#{rel}" end @@ -435,6 +460,48 @@ def save_edits(rel, dir) atomic_write(File.join(dir, 'album.json'), JSON.pretty_generate(data)) end +# ── Background update ───────────────────────────────────────────────────────── + +post '/admin/update' do + require_admin! + rel = params[:rel].to_s.chomp('/') + job_id = SecureRandom.hex(8) + script = File.join(__dir__, 'scripts', 'update.rb') + cmd = rel.empty? ? ['ruby', script] : ['ruby', script, rel] + + UPDATE_JOBS_MUTEX.synchronize do + UPDATE_JOBS[job_id] = { status: :running, lines: [] } + end + + Thread.new do + begin + IO.popen(cmd, err: [:child, :out]) do |io| + io.each_line do |line| + UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:lines] << line.chomp } + end + end + code = $?.exitstatus + UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:status] = code == 0 ? :done : :error } + rescue => e + UPDATE_JOBS_MUTEX.synchronize do + UPDATE_JOBS[job_id][:status] = :error + UPDATE_JOBS[job_id][:lines] << "Error: #{e.message}" + end + end + end + + content_type :json + { job_id: job_id }.to_json +end + +get '/admin/update/:id' do + require_admin! + job = UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[params[:id]]&.dup } + halt 404 unless job + content_type :json + { status: job[:status], lines: job[:lines] }.to_json +end + # ── Thumbnail generation ─────────────────────────────────────────────────────── def generate_thumb(source, dest, ext) diff --git a/public/css/style.css b/public/css/style.css index b5d713e..e6ebff3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -378,3 +378,16 @@ legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; } .lb-stage { padding: 48px 40px; } .files-table { font-size: .78rem; } } + +/* ── Admin update panel ────────────────────────────────────────────────── */ +.admin-update { margin-top: 32px; } +.admin-update h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 6px; } +.update-hint { font-size: .85rem; color: var(--text-dim); margin: 0 0 12px; } +.update-panel { margin-top: 12px; } +.update-status { font-size: .9rem; font-weight: 500; margin-bottom: 6px; } +.update-status.running { color: var(--text-dim); } +.update-status.done { color: #2a9d2a; } +.update-status.error { color: #c0392b; } +.update-log { background: #1a1a1a; color: #e0e0e0; font-size: .8rem; line-height: 1.5; + padding: 12px 14px; border-radius: var(--radius); max-height: 340px; + overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin: 0; } diff --git a/views/admin/album.erb b/views/admin/album.erb index 9d98c5e..49ec4ca 100644 --- a/views/admin/album.erb +++ b/views/admin/album.erb @@ -14,6 +14,13 @@ <form method="post" action="/admin/edit/<%= @rel %>"> <fieldset class="album-settings"> <legend>Album</legend> + <% unless @rel.empty? %> + <div class="form-row"> + <label>Folder name + <input type="text" name="folder_name" value="<%= File.basename(@dir) %>"> + </label> + </div> + <% end %> <div class="form-row"> <label>Title override <input type="text" name="album_title" value="<%= @data['title'] %>" placeholder="(use directory name)"> @@ -108,4 +115,62 @@ </ul> </section> <% end %> + + <section class="admin-update"> + <h2>Update</h2> + <p class="update-hint">Scans this album for new/removed files, extracts EXIF data, and generates missing thumbnails.</p> + <button id="update-btn" class="btn" onclick="startUpdate()">Run Update</button> + <div id="update-panel" class="update-panel hidden"> + <div id="update-status" class="update-status"></div> + <pre id="update-log" class="update-log"></pre> + </div> + </section> + + <script> + async function startUpdate() { + const btn = document.getElementById('update-btn'); + const panel = document.getElementById('update-panel'); + const log = document.getElementById('update-log'); + const status = document.getElementById('update-status'); + const rel = <%= @rel.to_json %>; + + btn.disabled = true; + btn.textContent = 'Running…'; + log.textContent = ''; + status.textContent = 'Running…'; + status.className = 'update-status running'; + panel.classList.remove('hidden'); + + const res = await fetch('/admin/update', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ rel }) + }); + const { job_id } = await res.json(); + + let seen = 0; + const poll = setInterval(async () => { + const r = await fetch('/admin/update/' + job_id); + const data = await r.json(); + const fresh = data.lines.slice(seen); + if (fresh.length) { + log.textContent += fresh.join('\n') + '\n'; + log.scrollTop = log.scrollHeight; + seen = data.lines.length; + } + if (data.status !== 'running') { + clearInterval(poll); + btn.disabled = false; + btn.textContent = 'Run Update'; + if (data.status === 'done') { + status.textContent = 'Done ✓'; + status.className = 'update-status done'; + } else { + status.textContent = 'Error ✗'; + status.className = 'update-status error'; + } + } + }, 1500); + } + </script> </div> |
