From f40ace64a828dbb4cf74f2d4710136e1f1dadded Mon Sep 17 00:00:00 2001 From: Petr Date: Sun, 12 Jul 2026 11:12:37 +0200 Subject: [PATCH] feat(settings): add user profile photo upload and tabbed layout --- lib/volumen/admin_routes.rb | 12 + lib/volumen/server.rb | 21 ++ lib/volumen/store.rb | 11 +- lib/volumen/users.rb | 13 +- lib/volumen/web/views/layout.erb | 122 +++++++++- lib/volumen/web/views/settings.erb | 356 ++++++++++++++++++----------- 6 files changed, 384 insertions(+), 151 deletions(-) diff --git a/lib/volumen/admin_routes.rb b/lib/volumen/admin_routes.rb index 7ae8d3b..272c2a2 100644 --- a/lib/volumen/admin_routes.rb +++ b/lib/volumen/admin_routes.rb @@ -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! diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb index 58f9efd..15c9632 100644 --- a/lib/volumen/server.rb +++ b/lib/volumen/server.rb @@ -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"]) diff --git a/lib/volumen/store.rb b/lib/volumen/store.rb index 1ba6595..8a9bd19 100644 --- a/lib/volumen/store.rb +++ b/lib/volumen/store.rb @@ -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 diff --git a/lib/volumen/users.rb b/lib/volumen/users.rb index c4f8551..4e3bfde 100644 --- a/lib/volumen/users.rb +++ b/lib/volumen/users.rb @@ -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 diff --git a/lib/volumen/web/views/layout.erb b/lib/volumen/web/views/layout.erb index eb117f7..48d06b0 100644 --- a/lib/volumen/web/views/layout.erb +++ b/lib/volumen/web/views/layout.erb @@ -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 @@