summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app.rb67
-rw-r--r--public/css/style.css13
-rw-r--r--views/admin/album.erb65
3 files changed, 145 insertions, 0 deletions
diff --git a/app.rb b/app.rb
index 5aba1b4..0678c67 100644
--- a/app.rb
+++ b/app.rb
@@ -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>