feat(settings): add user profile photo upload and tabbed layout

This commit is contained in:
2026-07-12 11:12:37 +02:00
parent ff0452bcc3
commit f40ace64a8
6 changed files with 384 additions and 151 deletions
+12
View File
@@ -139,6 +139,18 @@ module Volumen
change_fediverse_creator
end
app.post "/admin/settings/photo" do
require_login!
check_csrf!
change_photo
end
app.post "/admin/settings/photo/remove" do
require_login!
check_csrf!
remove_photo
end
app.post "/admin/settings/users" do
require_login!
require_admin!
+21
View File
@@ -207,6 +207,10 @@ module Volumen
current_user_record&.fediverse_creator
end
def current_user_photo
current_user_record&.photo
end
def admin?
current_role == "admin"
end
@@ -325,6 +329,23 @@ module Volumen
render_settings(notice: "Fediverse handle updated.")
end
def change_photo
upload = params["photo"]
unless upload.is_a?(Hash) && upload[:tempfile]
return render_settings(error: "No file selected.")
end
validate_upload!(upload)
url = store.store_user_photo(current_user, upload[:filename], upload[:tempfile])
users.update_photo(current_user, url)
render_settings(notice: "Profile photo updated.")
end
def remove_photo
users.update_photo(current_user, nil)
render_settings(notice: "Profile photo removed.")
end
def create_user
name = presence(params["username"])
password = presence(params["password"])
+10 -1
View File
@@ -61,8 +61,17 @@ module Volumen
end
def store_upload(original_name, source)
FileUtils.mkdir_p(media_dir)
name = safe_media_name(original_name)
FileUtils.mkdir_p(media_dir)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}"
end
def store_user_photo(username, original_name, source)
ext = File.extname(original_name.to_s).gsub(/[^a-zA-Z0-9.]/, "").downcase
ext = ".webp" if ext.empty?
name = "#{username}-#{SecureRandom.hex(4)}#{ext}"
FileUtils.mkdir_p(media_dir)
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
"/media/#{name}"
end
+9 -4
View File
@@ -15,7 +15,7 @@ module Volumen
ROLES = %w[admin author].freeze
DEFAULT_ROLE = "author"
User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator)
User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator, :photo)
def initialize(path:, bootstrap_hash: nil)
@path = path
@@ -33,7 +33,7 @@ module Volumen
elsif @bootstrap_hash.empty?
[]
else
[User.new("admin", @bootstrap_hash, "admin", nil, nil)]
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
end
end
end
@@ -54,7 +54,7 @@ module Volumen
def add(username, password, role = DEFAULT_ROLE)
return nil if username.to_s.empty? || find(username)
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil)
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil, nil)
persist(all + [user])
user
end
@@ -69,6 +69,10 @@ module Volumen
end
end
def update_photo(username, path)
mutate(username) { |user| user.photo = path }
end
def update_password(username, password)
mutate(username) { |user| user.password_hash = Password.hash(password) }
end
@@ -132,7 +136,7 @@ module Volumen
data = TomlRB.load_file(@path)
Array(data["users"]).map do |entry|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
entry["name"], entry["fediverse_creator"])
entry["name"], entry["fediverse_creator"], entry["photo"])
end
end
@@ -151,6 +155,7 @@ module Volumen
}
hash["name"] = user.name if user.name
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
hash["photo"] = user.photo if user.photo
hash
end
end
+113 -7
View File
@@ -102,6 +102,11 @@
display: grid; place-items: center; font-weight: 600; font-size: 0.78rem;
flex-shrink: 0;
}
.avatar-img {
background: var(--surface-2);
object-fit: cover;
padding: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-info .name { font-size: 0.82rem; font-weight: 600; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -360,17 +365,114 @@
.data-table tr:last-child td { border-bottom: none; }
/* --- Settings --- */
.settings-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 1.5rem;
align-items: start;
}
.settings-nav {
display: flex; gap: 0.4rem; margin-bottom: 1.25rem;
border-bottom: 1px solid var(--border); padding-bottom: 0;
display: flex; flex-direction: column; gap: 0.15rem;
position: sticky; top: 5rem;
padding: 0.4rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.settings-nav a {
padding: 0.55rem 0.9rem; color: var(--fg-muted); text-decoration: none;
font-size: 0.85rem; font-weight: 550; border-bottom: 2px solid transparent;
margin-bottom: -1px;
display: flex; align-items: center; gap: 0.6rem;
padding: 0.55rem 0.75rem;
font-size: 0.85rem; font-weight: 550;
color: var(--fg-muted);
text-decoration: none;
border-radius: var(--radius-sm);
transition: background 0.12s, color 0.12s;
}
.settings-nav a:hover { background: var(--surface-2); color: var(--fg); }
.settings-nav a.active { background: var(--accent-soft); color: var(--accent); }
.settings-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
.settings-panels { display: flex; flex-direction: column; gap: 1.25rem; min-width: 0; }
.settings-panels .surface { padding: 0; overflow: hidden; }
.settings-panels .surface-head {
padding: 1.5rem 1.75rem 1.25rem;
margin-bottom: 0;
border-bottom: 1px solid var(--border);
}
.settings-section {
display: flex; gap: 1.25rem;
padding: 1.5rem 1.75rem;
border-top: 1px solid var(--border);
}
.settings-section:first-of-type { border-top: none; }
.settings-section-icon {
width: 40px; height: 40px; flex-shrink: 0;
background: var(--accent-soft); color: var(--accent);
border-radius: 10px;
display: grid; place-items: center;
}
.settings-section-icon svg { width: 20px; height: 20px; }
.settings-section-body { flex: 1; min-width: 0; }
.settings-section-body h3 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.2rem; }
.settings-section-desc { font-size: 0.82rem; color: var(--fg-muted); margin: 0 0 1rem; line-height: 1.5; }
.settings-inline-form { display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap; }
.settings-inline-form .field { flex: 1; min-width: 220px; margin-bottom: 0; }
.settings-inline-form .btn { margin-bottom: 1px; }
.settings-photo-form { margin-top: 0.5rem; }
.photo-input-row { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; }
.photo-preview {
width: 80px; height: 80px; border-radius: 50%; object-fit: cover;
border: 2px solid var(--border); flex-shrink: 0;
}
.photo-preview-placeholder {
background: var(--accent-soft); color: var(--accent);
display: grid; place-items: center; font-weight: 600; font-size: 1.75rem;
}
.photo-input-fields { flex: 1; min-width: 220px; }
.file-input {
font: inherit; color: var(--fg); padding: 0.5rem 0.75rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); width: 100%; font-size: 0.85rem;
}
.photo-buttons { display: flex; gap: 0.5rem; margin-top: 0.6rem; }
.btn-danger { color: var(--danger); }
.users-list { padding: 0.5rem 0; }
.user-row {
display: flex; align-items: center; gap: 0.85rem;
padding: 0.85rem 1.75rem;
border-top: 1px solid var(--border);
}
.user-row:first-child { border-top: none; }
.user-avatar {
width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex-shrink: 0;
}
.user-avatar-placeholder {
background: var(--accent-soft); color: var(--accent);
display: grid; place-items: center; font-weight: 600; font-size: 0.9rem;
}
.user-info { flex: 1; min-width: 0; }
.user-name { font-weight: 600; font-size: 0.9rem; color: var(--fg); }
.user-username { font-size: 0.78rem; color: var(--fg-muted); font-family: var(--font-mono); }
.user-role { display: flex; align-items: center; }
.select-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; width: 110px; }
.add-user-form {
padding: 1.5rem 1.75rem;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.add-user-form h3 { color: var(--fg-muted); }
@media (max-width: 760px) {
.settings-layout { grid-template-columns: 1fr; }
.settings-nav { position: static; flex-direction: row; overflow-x: auto; }
.settings-nav a { white-space: nowrap; }
.settings-section { flex-direction: column; gap: 0.85rem; }
.settings-section-icon { width: 36px; height: 36px; }
.settings-section-icon svg { width: 18px; height: 18px; }
.photo-input-row { flex-direction: column; align-items: flex-start; }
}
.settings-nav a:hover { color: var(--fg); }
.settings-nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
[hidden] { display: none !important; }
@@ -515,7 +617,11 @@
</div>
</nav>
<div class="sidebar-foot">
<% if current_user_photo %>
<img class="avatar avatar-img" src="<%= h(current_user_photo) %>" alt="">
<% else %>
<div class="avatar"><%= h((current_user_name || current_user).to_s[0].upcase) %></div>
<% end %>
<div class="user-info">
<div class="name"><%= h(current_user_name || current_user) %></div>
<div class="role"><%= h(current_role) %></div>
+153 -73
View File
@@ -18,17 +18,98 @@
</div>
<% end %>
<div class="surface">
<div class="settings-layout">
<nav class="settings-nav" aria-label="Settings sections">
<a href="#account" class="active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Account
</a>
<% if admin? %>
<a href="#users">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Users
</a>
<% end %>
</nav>
<div class="settings-panels">
<section id="account" class="surface">
<div class="surface-head">
<div>
<h2>Your account</h2>
<h2>Account</h2>
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
</div>
</div>
<form method="post" action="<%= to("/admin/settings/password") %>">
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><path d="M12 11v6"/><path d="M9 14h6"/></svg>
</div>
<div class="settings-section-body">
<h3>Profile photo</h3>
<p class="settings-section-desc">WebP or AVIF, max 10 MB. Shown in the sidebar.</p>
<form method="post" action="<%= to("/admin/settings/photo") %>" enctype="multipart/form-data" class="settings-photo-form">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="photo-input-row">
<% if current_user_record&.photo %>
<img src="<%= h(current_user_record.photo) %>" alt="" class="photo-preview">
<% else %>
<div class="photo-preview photo-preview-placeholder"><%= h((current_user_record&.name || current_user).to_s[0].upcase) %></div>
<% end %>
<div class="photo-input-fields">
<input type="file" name="photo" accept="image/webp,image/avif" required class="file-input">
<div class="photo-buttons">
<button type="submit" class="btn btn-primary">Upload</button>
<% if current_user_record&.photo %>
<button type="submit" form="remove-photo-form" class="btn btn-ghost btn-danger">Remove</button>
<% end %>
</div>
</div>
</div>
</form>
<% if current_user_record&.photo %>
<form id="remove-photo-form" method="post" action="<%= to("/admin/settings/photo/remove") %>" style="display: none;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
</form>
<% end %>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
</div>
<div class="settings-section-body">
<h3>Identity</h3>
<p class="settings-section-desc">Display name pre-fills the author field. Fediverse handle pre-fills the creator.</p>
<form method="post" action="<%= to("/admin/settings/name") %>" class="settings-inline-form">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field">
<label for="name">Display name (real name)</label>
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name">
</div>
<button type="submit" class="btn">Save name</button>
</form>
<form method="post" action="<%= to("/admin/settings/fediverse") %>" class="settings-inline-form" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<div class="field">
<label for="fediverse_creator">Fediverse handle</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator" value="<%= h(current_user_record&.fediverse_creator) %>" placeholder="@user@instance.tld">
</div>
<button type="submit" class="btn">Save handle</button>
</form>
</div>
</div>
<div class="settings-section">
<div class="settings-section-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<div class="settings-section-body">
<h3>Security</h3>
<p class="settings-section-desc">Change your password and username. You can also lock yourself out.</p>
<form method="post" action="<%= to("/admin/settings/password") %>" class="settings-inline-form">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3 style="margin: 0 0 0.75rem;">Change password</h3>
<div class="field-row">
<div class="field">
<label for="current_password">Current password</label>
@@ -39,52 +120,22 @@
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn btn-primary">Update password</button>
<button type="submit" class="btn">Update password</button>
</form>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/name") %>">
<form method="post" action="<%= to("/admin/settings/username") %>" class="settings-inline-form" style="margin-top: 1rem;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3 style="margin: 0 0 0.75rem;">Display name</h3>
<div class="field">
<label for="name">Real name</label>
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name" style="max-width: 360px;">
<p class="help">Used to pre-fill the author field for new posts. Leave empty to use your username.</p>
</div>
<button type="submit" class="btn">Save name</button>
</form>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/fediverse") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3 style="margin: 0 0 0.75rem;">Fediverse handle</h3>
<div class="field">
<label for="fediverse_creator">Fediverse creator</label>
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
value="<%= h(current_user_record&.fediverse_creator) %>"
placeholder="@user@instance.tld" style="max-width: 360px;">
<p class="help">Used to pre-fill the Fediverse creator for new posts.</p>
</div>
<button type="submit" class="btn">Save handle</button>
</form>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
<form method="post" action="<%= to("/admin/settings/username") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3 style="margin: 0 0 0.75rem;">Change username</h3>
<div class="field">
<label for="username">New username</label>
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>" style="max-width: 360px;">
<label for="username">Username</label>
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>">
</div>
<button type="submit" class="btn">Update username</button>
</form>
</div>
</div>
</div>
</section>
<% if admin? %>
<div class="surface">
<% if admin? %>
<section id="users" class="surface">
<div class="surface-head">
<div>
<h2>Users</h2>
@@ -92,58 +143,53 @@
</div>
</div>
<table class="data-table">
<thead>
<tr><th>Username</th><th>Role</th><th></th></tr>
</thead>
<tbody>
<div class="users-list">
<% @users.each do |user| %>
<tr>
<td>
<strong><%= h(user.username) %></strong>
<div class="user-row">
<% if user.photo %>
<img src="<%= h(user.photo) %>" alt="" class="user-avatar">
<% else %>
<div class="user-avatar user-avatar-placeholder"><%= h((user.name || user.username).to_s[0].upcase) %></div>
<% end %>
<div class="user-info">
<div class="user-name">
<%= h(user.name || user.username) %>
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
</td>
<td>
</div>
<div class="user-username">@<%= h(user.username) %></div>
</div>
<div class="user-role">
<% if user.username == current_user %>
<span class="badge badge-neutral"><%= h(user.role) %></span>
<% else %>
<form method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"
style="margin: 0;">
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>" style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<select class="select" name="role" onchange="this.form.submit()" style="width: 130px; padding: 0.35rem 0.6rem; font-size: 0.82rem;">
<select class="select select-sm" name="role" onchange="this.form.submit()">
<% Volumen::Users::ROLES.each do |role| %>
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
<% end %>
</select>
</form>
<% end %>
</td>
<td>
</div>
<% unless user.username == current_user %>
<form method="post"
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
onsubmit="return confirm('Remove this user?');"
style="margin: 0;">
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
</form>
<% end %>
</td>
</tr>
</div>
<% end %>
</tbody>
</table>
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
</div>
<div class="add-user-form">
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
<form method="post" action="<%= to("/admin/settings/users") %>">
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
<div class="field-row-3">
<div class="field">
<label for="new-username">Username</label>
<input id="new-username" class="input" type="text" name="username">
<input id="new-username" class="input" type="text" name="username" placeholder="jane-doe">
</div>
<div class="field">
<label for="new-password">Password</label>
@@ -161,4 +207,38 @@
<button type="submit" class="btn btn-primary">Add user</button>
</form>
</div>
<% end %>
</section>
<% end %>
</div>
</div>
<script>
(function () {
var sections = document.querySelectorAll(".settings-panels .surface[id]");
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
if (!sections.length || !navLinks.length) return;
function showSection(id) {
sections.forEach(function (s) {
s.hidden = s.id !== id;
});
navLinks.forEach(function (a) {
a.classList.toggle("active", a.getAttribute("href") === "#" + id);
});
try { history.replaceState(null, "", "#" + id); } catch (e) {}
window.scrollTo({ top: 0, behavior: "smooth" });
}
navLinks.forEach(function (link) {
link.addEventListener("click", function (event) {
event.preventDefault();
var id = link.getAttribute("href").slice(1);
showSection(id);
});
});
var initial = (window.location.hash || "").slice(1);
var hasInitial = Array.from(sections).some(function (s) { return s.id === initial; });
showSection(hasInitial ? initial : sections[0].id);
})();
</script>