feat(settings): add user profile photo upload and tabbed layout
This commit is contained in:
@@ -139,6 +139,18 @@ module Volumen
|
|||||||
change_fediverse_creator
|
change_fediverse_creator
|
||||||
end
|
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
|
app.post "/admin/settings/users" do
|
||||||
require_login!
|
require_login!
|
||||||
require_admin!
|
require_admin!
|
||||||
|
|||||||
@@ -207,6 +207,10 @@ module Volumen
|
|||||||
current_user_record&.fediverse_creator
|
current_user_record&.fediverse_creator
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def current_user_photo
|
||||||
|
current_user_record&.photo
|
||||||
|
end
|
||||||
|
|
||||||
def admin?
|
def admin?
|
||||||
current_role == "admin"
|
current_role == "admin"
|
||||||
end
|
end
|
||||||
@@ -325,6 +329,23 @@ module Volumen
|
|||||||
render_settings(notice: "Fediverse handle updated.")
|
render_settings(notice: "Fediverse handle updated.")
|
||||||
end
|
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
|
def create_user
|
||||||
name = presence(params["username"])
|
name = presence(params["username"])
|
||||||
password = presence(params["password"])
|
password = presence(params["password"])
|
||||||
|
|||||||
+10
-1
@@ -61,8 +61,17 @@ module Volumen
|
|||||||
end
|
end
|
||||||
|
|
||||||
def store_upload(original_name, source)
|
def store_upload(original_name, source)
|
||||||
FileUtils.mkdir_p(media_dir)
|
|
||||||
name = safe_media_name(original_name)
|
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) }
|
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||||
"/media/#{name}"
|
"/media/#{name}"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ module Volumen
|
|||||||
ROLES = %w[admin author].freeze
|
ROLES = %w[admin author].freeze
|
||||||
DEFAULT_ROLE = "author"
|
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)
|
def initialize(path:, bootstrap_hash: nil)
|
||||||
@path = path
|
@path = path
|
||||||
@@ -33,7 +33,7 @@ module Volumen
|
|||||||
elsif @bootstrap_hash.empty?
|
elsif @bootstrap_hash.empty?
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
[User.new("admin", @bootstrap_hash, "admin", nil, nil)]
|
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -54,7 +54,7 @@ module Volumen
|
|||||||
def add(username, password, role = DEFAULT_ROLE)
|
def add(username, password, role = DEFAULT_ROLE)
|
||||||
return nil if username.to_s.empty? || find(username)
|
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])
|
persist(all + [user])
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
@@ -69,6 +69,10 @@ module Volumen
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_photo(username, path)
|
||||||
|
mutate(username) { |user| user.photo = path }
|
||||||
|
end
|
||||||
|
|
||||||
def update_password(username, password)
|
def update_password(username, password)
|
||||||
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
||||||
end
|
end
|
||||||
@@ -132,7 +136,7 @@ module Volumen
|
|||||||
data = TomlRB.load_file(@path)
|
data = TomlRB.load_file(@path)
|
||||||
Array(data["users"]).map do |entry|
|
Array(data["users"]).map do |entry|
|
||||||
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
|
||||||
entry["name"], entry["fediverse_creator"])
|
entry["name"], entry["fediverse_creator"], entry["photo"])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -151,6 +155,7 @@ module Volumen
|
|||||||
}
|
}
|
||||||
hash["name"] = user.name if user.name
|
hash["name"] = user.name if user.name
|
||||||
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
|
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
|
||||||
|
hash["photo"] = user.photo if user.photo
|
||||||
hash
|
hash
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -102,6 +102,11 @@
|
|||||||
display: grid; place-items: center; font-weight: 600; font-size: 0.78rem;
|
display: grid; place-items: center; font-weight: 600; font-size: 0.78rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.avatar-img {
|
||||||
|
background: var(--surface-2);
|
||||||
|
object-fit: cover;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
.user-info { flex: 1; min-width: 0; }
|
.user-info { flex: 1; min-width: 0; }
|
||||||
.user-info .name { font-size: 0.82rem; font-weight: 600; color: var(--fg);
|
.user-info .name { font-size: 0.82rem; font-weight: 600; color: var(--fg);
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
@@ -360,17 +365,114 @@
|
|||||||
.data-table tr:last-child td { border-bottom: none; }
|
.data-table tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
/* --- Settings --- */
|
/* --- Settings --- */
|
||||||
|
.settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
.settings-nav {
|
.settings-nav {
|
||||||
display: flex; gap: 0.4rem; margin-bottom: 1.25rem;
|
display: flex; flex-direction: column; gap: 0.15rem;
|
||||||
border-bottom: 1px solid var(--border); padding-bottom: 0;
|
position: sticky; top: 5rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.settings-nav a {
|
.settings-nav a {
|
||||||
padding: 0.55rem 0.9rem; color: var(--fg-muted); text-decoration: none;
|
display: flex; align-items: center; gap: 0.6rem;
|
||||||
font-size: 0.85rem; font-weight: 550; border-bottom: 2px solid transparent;
|
padding: 0.55rem 0.75rem;
|
||||||
margin-bottom: -1px;
|
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; }
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
@@ -515,7 +617,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-foot">
|
<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>
|
<div class="avatar"><%= h((current_user_name || current_user).to_s[0].upcase) %></div>
|
||||||
|
<% end %>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="name"><%= h(current_user_name || current_user) %></div>
|
<div class="name"><%= h(current_user_name || current_user) %></div>
|
||||||
<div class="role"><%= h(current_role) %></div>
|
<div class="role"><%= h(current_role) %></div>
|
||||||
|
|||||||
@@ -18,17 +18,98 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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 class="surface-head">
|
||||||
<div>
|
<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>
|
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
|
||||||
</div>
|
</div>
|
||||||
</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 %>">
|
<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-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="current_password">Current password</label>
|
<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">
|
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Update password</button>
|
<button type="submit" class="btn">Update password</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="<%= to("/admin/settings/username") %>" class="settings-inline-form" style="margin-top: 1rem;">
|
||||||
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
|
|
||||||
|
|
||||||
<form method="post" action="<%= to("/admin/settings/name") %>">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||||
<h3 style="margin: 0 0 0.75rem;">Display name</h3>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="name">Real name</label>
|
<label for="username">Username</label>
|
||||||
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name" style="max-width: 360px;">
|
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>">
|
||||||
<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;">
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn">Update username</button>
|
<button type="submit" class="btn">Update username</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<% if admin? %>
|
<% if admin? %>
|
||||||
<div class="surface">
|
<section id="users" class="surface">
|
||||||
<div class="surface-head">
|
<div class="surface-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
@@ -92,58 +143,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="data-table">
|
<div class="users-list">
|
||||||
<thead>
|
|
||||||
<tr><th>Username</th><th>Role</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @users.each do |user| %>
|
<% @users.each do |user| %>
|
||||||
<tr>
|
<div class="user-row">
|
||||||
<td>
|
<% if user.photo %>
|
||||||
<strong><%= h(user.username) %></strong>
|
<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 %>
|
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
<div class="user-username">@<%= h(user.username) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-role">
|
||||||
<% if user.username == current_user %>
|
<% if user.username == current_user %>
|
||||||
<span class="badge badge-neutral"><%= h(user.role) %></span>
|
<span class="badge badge-neutral"><%= h(user.role) %></span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<form method="post"
|
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>" style="margin: 0;">
|
||||||
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"
|
|
||||||
style="margin: 0;">
|
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<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| %>
|
<% Volumen::Users::ROLES.each do |role| %>
|
||||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
||||||
<% end %>
|
<% end %>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
|
||||||
<% unless user.username == current_user %>
|
<% unless user.username == current_user %>
|
||||||
<form method="post"
|
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
||||||
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 %>">
|
<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>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
|
|
||||||
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
|
|
||||||
|
|
||||||
|
<div class="add-user-form">
|
||||||
|
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
||||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
<form method="post" action="<%= to("/admin/settings/users") %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
<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-row-3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-username">Username</label>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="new-password">Password</label>
|
<label for="new-password">Password</label>
|
||||||
@@ -161,4 +207,38 @@
|
|||||||
<button type="submit" class="btn btn-primary">Add user</button>
|
<button type="submit" class="btn btn-primary">Add user</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<% end %>
|
<% 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user