summaryrefslogtreecommitdiffstats
path: root/views
diff options
context:
space:
mode:
authorKen <ken@jots.org>2026-05-09 04:41:03 +0000
committerKen <ken@jots.org>2026-05-09 04:41:03 +0000
commitc75beda743dfd6af63f512e928d0889d9ead3973 (patch)
treebed91fd4f9d36a905be0b1ef990457a1e37e567b /views
Initial commit — Albumen photo album
Ruby/Sinatra self-hosted photo album with directory hierarchy, per-photo captions and visibility, lightbox, slideshow, admin UI, and Let's Encrypt HTTPS via Apache reverse proxy on prouter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'views')
-rw-r--r--views/admin/album.erb101
-rw-r--r--views/admin/login.erb14
-rw-r--r--views/album.erb99
-rw-r--r--views/layout.erb25
-rw-r--r--views/slideshow.erb41
5 files changed, 280 insertions, 0 deletions
diff --git a/views/admin/album.erb b/views/admin/album.erb
new file mode 100644
index 0000000..f6b80d6
--- /dev/null
+++ b/views/admin/album.erb
@@ -0,0 +1,101 @@
+<div class="admin-album">
+ <div class="admin-nav">
+ <% if @rel.empty? %>
+ <a href="/browse/" class="btn btn-sm">View Root</a>
+ <% else %>
+ <% parent = @rel.include?('/') ? @rel.split('/')[0..-2].join('/') : '' %>
+ <a href="/admin/edit/<%= parent %>" class="btn btn-sm">← Parent</a>
+ <a href="/browse/<%= @rel %>" class="btn btn-sm">View Album</a>
+ <% end %>
+ </div>
+
+ <h1>Edit: <%= @title %></h1>
+
+ <form method="post" action="/admin/edit/<%= @rel %>">
+ <fieldset class="album-settings">
+ <legend>Album</legend>
+ <div class="form-row">
+ <label>Title override
+ <input type="text" name="album_title" value="<%= @data['title'] %>" placeholder="(use directory name)">
+ </label>
+ </div>
+ <div class="form-row">
+ <label>Description
+ <textarea name="album_description" rows="2"><%= @data['description'] %></textarea>
+ </label>
+ </div>
+ <div class="form-row form-row-inline">
+ <label>Cover image
+ <select name="album_cover">
+ <option value="">— auto (first image) —</option>
+ <% @files.each do |name| %>
+ <option value="<%= name %>"<%= ' selected' if @data['cover'] == name %>><%= name %></option>
+ <% end %>
+ </select>
+ </label>
+ <label class="checkbox-label">
+ <input type="checkbox" name="album_cover_dynamic" value="1"<%= ' checked' if @data['cover_dynamic'] %>>
+ Dynamic cover
+ </label>
+ <label class="checkbox-label">
+ <input type="checkbox" name="album_sort_reverse" value="1"<%= ' checked' if @data['sort_reverse'] %>>
+ Reverse sub-album order
+ </label>
+ <label class="checkbox-label">
+ <input type="hidden" name="album_visible" value="0">
+ <input type="checkbox" name="album_visible" value="1"<%= ' checked' if @data['visible'] != false %>>
+ Visible
+ </label>
+ </div>
+ </fieldset>
+
+ <% unless @files.empty? %>
+ <div class="files-section">
+ <h2>Files</h2>
+ <table class="files-table">
+ <thead>
+ <tr>
+ <th>Thumb</th>
+ <th>Filename</th>
+ <th>Title</th>
+ <th>Caption</th>
+ <th>Visible</th>
+ </tr>
+ </thead>
+ <tbody>
+ <% @files.each do |name| %>
+ <% meta = (@data['files'] || {})[name] || {} %>
+ <% file_rel = @rel.empty? ? name : "#{@rel}/#{name}" %>
+ <tr>
+ <td><img src="/thumb/<%= file_rel %>" width="60" height="60" loading="lazy" style="object-fit:cover"></td>
+ <td class="filename"><code><%= name %></code></td>
+ <td><input type="text" name="file_title[<%= name %>]" value="<%= ERB::Util.html_escape(meta['title'].to_s) %>" placeholder="<%= ERB::Util.html_escape(name) %>"></td>
+ <td><input type="text" name="file_caption[<%= name %>]" value="<%= ERB::Util.html_escape(meta['caption'].to_s) %>" placeholder="caption…"></td>
+ <td class="visible-cell">
+ <input type="hidden" name="file_visible[<%= name %>]" value="0">
+ <input type="checkbox" name="file_visible[<%= name %>]" value="1"<%= ' checked' if meta['visible'] != false %>>
+ </td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+ <% end %>
+
+ <div class="form-actions">
+ <button type="submit" class="btn">Save</button>
+ </div>
+ </form>
+
+ <% unless @sub_dirs.empty? %>
+ <section class="sub-albums-section">
+ <h2>Sub-albums</h2>
+ <ul class="sub-album-list">
+ <% @sub_dirs.each do |name| %>
+ <% sub_rel = @rel.empty? ? name : "#{@rel}/#{name}" %>
+ <li><a href="/admin/edit/<%= sub_rel %>"><%= name %></a></li>
+ <% end %>
+ </ul>
+ </section>
+ <% end %>
+</div>
diff --git a/views/admin/login.erb b/views/admin/login.erb
new file mode 100644
index 0000000..16f12d2
--- /dev/null
+++ b/views/admin/login.erb
@@ -0,0 +1,14 @@
+<div class="admin-login">
+ <h1>Admin Login</h1>
+ <% if defined?(@error) && @error %>
+ <p class="form-error"><%= @error %></p>
+ <% end %>
+ <form method="post" action="/admin/login">
+ <input type="hidden" name="return_to" value="<%= ERB::Util.html_escape(params['return_to'].to_s) %>">
+ <label>
+ Password
+ <input type="password" name="password" autofocus autocomplete="current-password">
+ </label>
+ <button type="submit" class="btn">Login</button>
+ </form>
+</div>
diff --git a/views/album.erb b/views/album.erb
new file mode 100644
index 0000000..077e119
--- /dev/null
+++ b/views/album.erb
@@ -0,0 +1,99 @@
+<div class="album-header">
+ <% unless @crumbs.empty? %>
+ <nav class="breadcrumbs">
+ <a href="/browse/">Home</a>
+ <% @crumbs.each do |c| %>
+ <span class="sep">›</span>
+ <a href="/browse/<%= c[:path] %>"><%= c[:name] %></a>
+ <% end %>
+ </nav>
+ <% end %>
+ <h1><%= @title %></h1>
+ <% if @desc %><p class="album-desc"><%= @desc %></p><% end %>
+ <div class="album-actions">
+ <% if @entries.any? { |e| %i[image video].include?(e[:type]) } %>
+ <a href="/slideshow/<%= @rel %>" class="btn">Slideshow</a>
+ <% end %>
+ </div>
+</div>
+
+<% unless @albums.empty? %>
+<section class="grid-section">
+ <% if @entries.any? %><h2 class="section-label">Albums</h2><% end %>
+ <div class="grid">
+ <% @albums.each do |a| %>
+ <% href = @rel.empty? ? "/browse/#{a[:name]}" : "/browse/#{@rel}/#{a[:name]}" %>
+ <a href="<%= href %>" class="card album-card">
+ <div class="thumb-wrap">
+ <% cover_rel = @rel.empty? ? "#{a[:name]}/#{a[:cover]}" : "#{@rel}/#{a[:name]}/#{a[:cover]}" %>
+ <% if a[:cover] %>
+ <img src="/thumb/<%= cover_rel %>" alt="<%= a[:title] %>" loading="lazy">
+ <% else %>
+ <div class="thumb-placeholder">📁</div>
+ <% end %>
+ </div>
+ <div class="album-label"><%= a[:title] %></div>
+ </a>
+ <% end %>
+ </div>
+</section>
+<% end %>
+
+<% unless @entries.empty? %>
+<section class="grid-section">
+ <% if @albums.any? %><h2 class="section-label">Photos &amp; Videos</h2><% end %>
+ <div class="grid" id="photo-grid">
+ <% @entries.each_with_index do |e, i| %>
+ <% file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}" %>
+ <div class="card media-card<%= ' hidden-item' unless e[:visible] %>"
+ data-index="<%= i %>"
+ data-type="<%= e[:type] %>"
+ data-src="/media/<%= file_rel %>"
+ data-title="<%= ERB::Util.html_escape(e[:title]) %>"
+ data-caption="<%= ERB::Util.html_escape(e[:caption].to_s) %>"
+ onclick="openLightbox(<%= i %>)">
+ <div class="thumb-wrap">
+ <img src="/thumb/<%= file_rel %>" alt="<%= ERB::Util.html_escape(e[:title]) %>" loading="lazy">
+ <% if e[:type] == :video %><span class="type-badge video-badge">▶</span><% end %>
+ <% if e[:type] == :audio %><span class="type-badge audio-badge">♪</span><% end %>
+ </div>
+ <% if e[:caption] %>
+ <div class="card-meta"><p class="card-caption"><%= e[:caption] %></p></div>
+ <% end %>
+ </div>
+ <% end %>
+ </div>
+</section>
+<% end %>
+
+<% if @albums.empty? && @entries.empty? %>
+ <p class="empty-album">This album is empty.</p>
+<% end %>
+
+<div id="lightbox" class="lightbox hidden" role="dialog" aria-modal="true">
+ <button class="lb-btn lb-close" onclick="closeLightbox()" aria-label="Close">✕</button>
+ <div class="lb-stage" onclick="closeLightbox()">
+ <div class="lb-media" onclick="event.stopPropagation()">
+ <img id="lb-img" src="" alt="" class="hidden">
+ <video id="lb-video" controls class="hidden"></video>
+ <audio id="lb-audio" controls class="hidden"></audio>
+ <button class="lb-btn lb-prev" onclick="lbNav(-1)" aria-label="Previous">‹</button>
+ <button class="lb-btn lb-next" onclick="lbNav(1)" aria-label="Next">›</button>
+ </div>
+ </div>
+ <div class="lb-caption-bar">
+ <span id="lb-title"></span>
+ <span id="lb-caption"></span>
+ <a id="lb-download" href="" download class="btn btn-sm lb-action">↓ Original</a>
+ <button id="lb-copylink" onclick="lbCopyLink()" class="btn btn-sm lb-action">⧉ Copy link</button>
+ <span id="lb-counter"></span>
+ </div>
+</div>
+
+<script>
+const ENTRIES = <%= @entries.map { |e|
+ file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}"
+ e.merge(src: "/media/#{file_rel}")
+}.to_json %>;
+</script>
+<script src="/js/album.js"></script>
diff --git a/views/layout.erb b/views/layout.erb
new file mode 100644
index 0000000..3e798c4
--- /dev/null
+++ b/views/layout.erb
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><%= @title %> — Albumen</title>
+ <link rel="stylesheet" href="/css/style.css">
+</head>
+<body>
+ <header class="site-header">
+ <a href="/browse/" class="site-logo">Albumen</a>
+ <nav class="site-nav">
+ <% if admin? %>
+ <a href="/admin/edit/<%= defined?(@rel) ? @rel : '' %>">Edit Album</a>
+ <a href="/admin/logout">Logout</a>
+ <% else %>
+ <a href="/admin/login" class="nav-admin">Admin</a>
+ <% end %>
+ </nav>
+ </header>
+ <main>
+ <%= yield %>
+ </main>
+</body>
+</html>
diff --git a/views/slideshow.erb b/views/slideshow.erb
new file mode 100644
index 0000000..847da65
--- /dev/null
+++ b/views/slideshow.erb
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title><%= @title %> — Slideshow</title>
+ <link rel="stylesheet" href="/css/style.css">
+</head>
+<body class="slideshow-page">
+ <div id="slideshow">
+ <div id="ss-stage">
+ <img id="ss-img" src="" alt="" style="display:none">
+ <video id="ss-video" playsinline style="display:none"></video>
+ <div id="ss-caption-bar">
+ <span id="ss-title"></span>
+ <span id="ss-caption"></span>
+ </div>
+ </div>
+ <div id="ss-controls">
+ <a href="/browse/<%= @rel %>" class="btn btn-sm">← Album</a>
+ <button onclick="ssPrev()" class="btn btn-sm">‹ Prev</button>
+ <button onclick="ssToggle()" id="ss-play-btn" class="btn btn-sm">⏸ Pause</button>
+ <button onclick="ssNext()" class="btn btn-sm">Next ›</button>
+ <label class="ss-interval-label">
+ Interval
+ <input type="number" id="ss-interval" value="4" min="1" max="60" step="1"> s
+ </label>
+ </div>
+ <div id="ss-counter"></div>
+ </div>
+
+ <script>
+ const SS_ENTRIES = <%= @entries.map { |e|
+ file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}"
+ e.merge(src: "/media/#{file_rel}")
+ }.to_json %>;
+ const SS_REL = <%= @rel.to_json %>;
+ </script>
+ <script src="/js/slideshow.js"></script>
+</body>
+</html>