[skip ci] feat: prepare for public release
This commit is contained in:
@@ -25,8 +25,11 @@ import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/int
|
||||
import gleam/io
|
||||
import gleam/json
|
||||
import gleam/option
|
||||
import gleam/string
|
||||
|
||||
pub type Report {
|
||||
Report(
|
||||
@@ -194,10 +197,7 @@ pub fn list_reports(
|
||||
"reported_guild_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_icon_hash <- decode.field(
|
||||
"reported_guild_icon_hash",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
let reported_guild_icon_hash = option.None
|
||||
use reported_guild_invite_code <- decode.field(
|
||||
"reported_guild_invite_code",
|
||||
decode.optional(decode.string),
|
||||
@@ -518,14 +518,15 @@ pub fn get_report_detail(
|
||||
|
||||
let context_message_decoder = {
|
||||
use id <- decode.field("id", decode.string)
|
||||
use channel_id <- decode.field(
|
||||
"channel_id",
|
||||
decode.optional(decode.string),
|
||||
use channel_id <- decode.optional_field("channel_id", "", decode.string)
|
||||
use author_id <- decode.optional_field("author_id", "", decode.string)
|
||||
use author_username <- decode.optional_field(
|
||||
"author_username",
|
||||
"",
|
||||
decode.string,
|
||||
)
|
||||
use author_id <- decode.field("author_id", decode.string)
|
||||
use author_username <- decode.field("author_username", decode.string)
|
||||
use content <- decode.field("content", decode.string)
|
||||
use timestamp <- decode.field("timestamp", decode.string)
|
||||
use content <- decode.optional_field("content", "", decode.string)
|
||||
use timestamp <- decode.optional_field("timestamp", "", decode.string)
|
||||
use attachments <- decode.optional_field(
|
||||
"attachments",
|
||||
[],
|
||||
@@ -533,7 +534,7 @@ pub fn get_report_detail(
|
||||
)
|
||||
decode.success(Message(
|
||||
id: id,
|
||||
channel_id: option.unwrap(channel_id, ""),
|
||||
channel_id: channel_id,
|
||||
author_id: author_id,
|
||||
author_username: author_username,
|
||||
content: content,
|
||||
@@ -608,8 +609,9 @@ pub fn get_report_detail(
|
||||
"reported_guild_name",
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_icon_hash <- decode.field(
|
||||
use reported_guild_icon_hash <- decode.optional_field(
|
||||
"reported_guild_icon_hash",
|
||||
option.None,
|
||||
decode.optional(decode.string),
|
||||
)
|
||||
use reported_guild_invite_code <- decode.field(
|
||||
@@ -680,7 +682,15 @@ pub fn get_report_detail(
|
||||
|
||||
case json.parse(resp.body, report_decoder) {
|
||||
Ok(result) -> Ok(result)
|
||||
Error(_) -> Error(ServerError)
|
||||
Error(err) -> {
|
||||
io.println(
|
||||
"reports.get_report_detail decode failed: "
|
||||
<> string.inspect(err)
|
||||
<> " body="
|
||||
<> string.slice(resp.body, 0, 4000),
|
||||
)
|
||||
Error(ServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
|
||||
@@ -699,7 +709,20 @@ pub fn get_report_detail(
|
||||
Error(Forbidden(message))
|
||||
}
|
||||
Ok(resp) if resp.status == 404 -> Error(NotFound)
|
||||
Ok(_resp) -> Error(ServerError)
|
||||
Error(_) -> Error(NetworkError)
|
||||
Ok(resp) -> {
|
||||
io.println(
|
||||
"reports.get_report_detail unexpected status "
|
||||
<> int.to_string(resp.status)
|
||||
<> " body="
|
||||
<> string.slice(resp.body, 0, 1000),
|
||||
)
|
||||
Error(ServerError)
|
||||
}
|
||||
Error(err) -> {
|
||||
io.println(
|
||||
"reports.get_report_detail network error: " <> string.inspect(err),
|
||||
)
|
||||
Error(NetworkError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,21 +100,36 @@ pub fn page_with_refresh(
|
||||
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
|
||||
[
|
||||
build_head_with_refresh(title, ctx, auto_refresh),
|
||||
h.body([a.class("min-h-screen bg-neutral-50 flex")], [
|
||||
sidebar(ctx, active_page),
|
||||
h.div([a.class("ml-64 flex-1 flex flex-col")], [
|
||||
header(ctx, session, current_admin),
|
||||
h.main([a.class("flex-1 p-8")], [
|
||||
h.div([a.class("max-w-7xl mx-auto")], [
|
||||
case flash_data {
|
||||
option.Some(_) ->
|
||||
h.div([a.class("mb-6")], [flash.view(flash_data)])
|
||||
option.None -> element.none()
|
||||
},
|
||||
content,
|
||||
]),
|
||||
]),
|
||||
h.body([a.class("min-h-screen bg-neutral-50 overflow-hidden")], [
|
||||
h.div([a.class("flex h-screen")], [
|
||||
sidebar(ctx, active_page),
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-sidebar-overlay", ""),
|
||||
a.class("fixed inset-0 bg-black/50 z-30 hidden lg:hidden"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
h.div(
|
||||
[
|
||||
a.class("flex-1 flex flex-col w-full h-screen overflow-y-auto"),
|
||||
],
|
||||
[
|
||||
header(ctx, session, current_admin),
|
||||
h.main([a.class("flex-1 p-4 sm:p-6 lg:p-8")], [
|
||||
h.div([a.class("w-full max-w-7xl mx-auto")], [
|
||||
case flash_data {
|
||||
option.Some(_) ->
|
||||
h.div([a.class("mb-6")], [flash.view(flash_data)])
|
||||
option.None -> element.none()
|
||||
},
|
||||
content,
|
||||
]),
|
||||
]),
|
||||
],
|
||||
),
|
||||
]),
|
||||
sidebar_interaction_script(),
|
||||
]),
|
||||
],
|
||||
)
|
||||
@@ -123,18 +138,37 @@ pub fn page_with_refresh(
|
||||
fn sidebar(ctx: Context, active_page: String) {
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-sidebar", ""),
|
||||
a.class(
|
||||
"w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0",
|
||||
"fixed inset-y-0 left-0 z-40 w-64 h-screen bg-neutral-900 text-white flex flex-col transform -translate-x-full transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-auto shadow-xl lg:shadow-none",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div([a.class("p-6 border-b border-neutral-800")], [
|
||||
h.a([href(ctx, "/users")], [
|
||||
h.h1([a.class("text-base font-semibold")], [
|
||||
element.text("Fluxer Admin"),
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"p-6 border-b border-neutral-800 flex items-center justify-between gap-3",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.a([href(ctx, "/users")], [
|
||||
h.h1([a.class("text-base font-semibold")], [
|
||||
element.text("Fluxer Admin"),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
h.button(
|
||||
[
|
||||
a.type_("button"),
|
||||
a.attribute("data-sidebar-close", ""),
|
||||
a.class(
|
||||
"lg:hidden inline-flex items-center justify-center p-2 rounded-md text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40",
|
||||
),
|
||||
a.attribute("aria-label", "Close sidebar"),
|
||||
],
|
||||
[element.text("Close")],
|
||||
),
|
||||
],
|
||||
),
|
||||
h.nav(
|
||||
[
|
||||
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
|
||||
@@ -304,11 +338,68 @@ fn header(
|
||||
h.header(
|
||||
[
|
||||
a.class(
|
||||
"bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between",
|
||||
"bg-white border-b border-neutral-200 px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between gap-4 sticky top-0 z-10",
|
||||
),
|
||||
],
|
||||
[
|
||||
render_user_info(ctx, session, current_admin),
|
||||
h.div([a.class("flex items-center gap-3 min-w-0")], [
|
||||
h.button(
|
||||
[
|
||||
a.type_("button"),
|
||||
a.attribute("data-sidebar-toggle", ""),
|
||||
a.class(
|
||||
"lg:hidden inline-flex items-center justify-center p-2 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400",
|
||||
),
|
||||
a.attribute("aria-label", "Toggle sidebar"),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"svg",
|
||||
[
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 24 24"),
|
||||
a.class("w-5 h-5"),
|
||||
a.attribute("fill", "none"),
|
||||
a.attribute("stroke", "currentColor"),
|
||||
a.attribute("stroke-width", "2"),
|
||||
],
|
||||
[
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "3"),
|
||||
a.attribute("y1", "6"),
|
||||
a.attribute("x2", "21"),
|
||||
a.attribute("y2", "6"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "3"),
|
||||
a.attribute("y1", "12"),
|
||||
a.attribute("x2", "21"),
|
||||
a.attribute("y2", "12"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
element.element(
|
||||
"line",
|
||||
[
|
||||
a.attribute("x1", "3"),
|
||||
a.attribute("y1", "18"),
|
||||
a.attribute("x2", "21"),
|
||||
a.attribute("y2", "18"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
render_user_info(ctx, session, current_admin),
|
||||
]),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/logout"),
|
||||
@@ -390,3 +481,58 @@ fn render_avatar(
|
||||
a.class("w-10 h-10 rounded-full"),
|
||||
])
|
||||
}
|
||||
|
||||
fn sidebar_interaction_script() {
|
||||
h.script(
|
||||
[a.attribute("defer", "defer")],
|
||||
"
|
||||
(function() {
|
||||
const sidebar = document.querySelector('[data-sidebar]');
|
||||
const overlay = document.querySelector('[data-sidebar-overlay]');
|
||||
const toggles = document.querySelectorAll('[data-sidebar-toggle]');
|
||||
const closes = document.querySelectorAll('[data-sidebar-close]');
|
||||
if (!sidebar || !overlay) return;
|
||||
|
||||
const open = () => {
|
||||
sidebar.classList.remove('-translate-x-full');
|
||||
overlay.classList.remove('hidden');
|
||||
document.body.classList.add('overflow-hidden');
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
sidebar.classList.add('-translate-x-full');
|
||||
overlay.classList.add('hidden');
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
};
|
||||
|
||||
toggles.forEach((btn) => btn.addEventListener('click', () => {
|
||||
if (sidebar.classList.contains('-translate-x-full')) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}));
|
||||
|
||||
closes.forEach((btn) => btn.addEventListener('click', close));
|
||||
overlay.addEventListener('click', close);
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') close();
|
||||
});
|
||||
|
||||
const syncForDesktop = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
overlay.classList.add('hidden');
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
sidebar.classList.remove('-translate-x-full');
|
||||
} else {
|
||||
sidebar.classList.add('-translate-x-full');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', syncForDesktop);
|
||||
syncForDesktop();
|
||||
})();
|
||||
",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ pub type Tab {
|
||||
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
|
||||
h.div([a.class("border-b border-neutral-200 mb-6")], [
|
||||
h.nav(
|
||||
[a.class("flex gap-6")],
|
||||
[a.class("flex gap-6 overflow-x-auto no-scrollbar -mb-px px-1")],
|
||||
list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
|
||||
),
|
||||
])
|
||||
@@ -36,9 +36,10 @@ pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
|
||||
|
||||
fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) {
|
||||
let class_active = case tab.active {
|
||||
True -> "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3"
|
||||
True ->
|
||||
"border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3 whitespace-nowrap"
|
||||
False ->
|
||||
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors"
|
||||
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors whitespace-nowrap"
|
||||
}
|
||||
|
||||
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])
|
||||
|
||||
@@ -23,7 +23,7 @@ import lustre/attribute as a
|
||||
import lustre/element
|
||||
import lustre/element/html as h
|
||||
|
||||
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden"
|
||||
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden overflow-x-auto"
|
||||
|
||||
pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
|
||||
|
||||
@@ -469,7 +469,10 @@ pub fn flex_row(
|
||||
pub fn flex_row_between(
|
||||
children: List(element.Element(a)),
|
||||
) -> element.Element(a) {
|
||||
h.div([a.class("mb-6 flex items-center justify-between")], children)
|
||||
h.div(
|
||||
[a.class("mb-6 flex flex-wrap items-center justify-between gap-3")],
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stack(
|
||||
@@ -570,35 +573,37 @@ pub fn data_table(
|
||||
h.div(
|
||||
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
|
||||
[
|
||||
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
|
||||
h.thead([a.class("bg-neutral-50")], [
|
||||
h.tr(
|
||||
[],
|
||||
list.map(columns, fn(col) {
|
||||
let TableColumn(header, _, _) = col
|
||||
h.th(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
|
||||
),
|
||||
],
|
||||
[element.text(header)],
|
||||
h.div([a.class("overflow-x-auto")], [
|
||||
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
|
||||
h.thead([a.class("bg-neutral-50")], [
|
||||
h.tr(
|
||||
[],
|
||||
list.map(columns, fn(col) {
|
||||
let TableColumn(header, _, _) = col
|
||||
h.th(
|
||||
[
|
||||
a.class(
|
||||
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
|
||||
),
|
||||
],
|
||||
[element.text(header)],
|
||||
)
|
||||
}),
|
||||
),
|
||||
]),
|
||||
h.tbody(
|
||||
[a.class("bg-white divide-y divide-neutral-200")],
|
||||
list.map(rows, fn(row) {
|
||||
h.tr(
|
||||
[a.class("hover:bg-neutral-50 transition-colors")],
|
||||
list.map(columns, fn(col) {
|
||||
let TableColumn(_, cell_class, render) = col
|
||||
h.td([a.class(cell_class)], [render(row)])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
]),
|
||||
h.tbody(
|
||||
[a.class("bg-white divide-y divide-neutral-200")],
|
||||
list.map(rows, fn(row) {
|
||||
h.tr(
|
||||
[a.class("hover:bg-neutral-50 transition-colors")],
|
||||
list.map(columns, fn(col) {
|
||||
let TableColumn(_, cell_class, render) = col
|
||||
h.td([a.class(cell_class)], [render(row)])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
]),
|
||||
],
|
||||
)
|
||||
@@ -629,7 +634,7 @@ pub fn custom_checkbox(
|
||||
]
|
||||
}
|
||||
|
||||
h.label([a.class("flex items-center gap-3 cursor-pointer group")], [
|
||||
h.label([a.class("flex items-center gap-3 cursor-pointer group w-full")], [
|
||||
h.input(checkbox_attrs),
|
||||
element.element(
|
||||
"svg",
|
||||
@@ -637,7 +642,7 @@ pub fn custom_checkbox(
|
||||
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
|
||||
a.attribute("viewBox", "0 0 256 256"),
|
||||
a.class(
|
||||
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors",
|
||||
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors flex-shrink-0",
|
||||
),
|
||||
],
|
||||
[
|
||||
@@ -655,8 +660,15 @@ pub fn custom_checkbox(
|
||||
),
|
||||
],
|
||||
),
|
||||
h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [
|
||||
element.text(label),
|
||||
h.div([a.class("flex-1 min-w-0")], [
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate",
|
||||
),
|
||||
],
|
||||
[element.text(label)],
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -259,6 +259,10 @@ pub const acl_user_delete = "user:delete"
|
||||
|
||||
pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
|
||||
|
||||
pub const acl_pending_verification_view = "pending_verification:view"
|
||||
|
||||
pub const acl_pending_verification_review = "pending_verification:review"
|
||||
|
||||
pub const acl_beta_codes_generate = "beta_codes:generate"
|
||||
|
||||
pub const acl_gift_codes_generate = "gift_codes:generate"
|
||||
@@ -506,6 +510,8 @@ pub fn get_all_acls() -> List(String) {
|
||||
acl_user_disable_suspicious,
|
||||
acl_user_delete,
|
||||
acl_user_cancel_bulk_message_deletion,
|
||||
acl_pending_verification_view,
|
||||
acl_pending_verification_review,
|
||||
acl_beta_codes_generate,
|
||||
acl_gift_codes_generate,
|
||||
acl_guild_lookup,
|
||||
|
||||
@@ -118,60 +118,81 @@ pub fn view(
|
||||
h.div(
|
||||
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
|
||||
[
|
||||
h.div([a.class("flex items-start gap-6")], [
|
||||
case
|
||||
avatar.get_guild_icon_url(
|
||||
ctx.media_endpoint,
|
||||
guild_data.id,
|
||||
guild_data.icon,
|
||||
True,
|
||||
)
|
||||
{
|
||||
option.Some(icon_url) ->
|
||||
h.div([a.class("flex-shrink-0")], [
|
||||
h.img([
|
||||
a.src(icon_url),
|
||||
a.alt(guild_data.name),
|
||||
a.class("w-24 h-24 rounded-full"),
|
||||
]),
|
||||
])
|
||||
option.None ->
|
||||
h.div([a.class("flex-shrink-0")], [
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
|
||||
),
|
||||
],
|
||||
[
|
||||
case
|
||||
avatar.get_guild_icon_url(
|
||||
ctx.media_endpoint,
|
||||
guild_data.id,
|
||||
guild_data.icon,
|
||||
True,
|
||||
)
|
||||
{
|
||||
option.Some(icon_url) ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600",
|
||||
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
element.text(avatar.get_initials_from_name(
|
||||
guild_data.name,
|
||||
)),
|
||||
h.img([
|
||||
a.src(icon_url),
|
||||
a.alt(guild_data.name),
|
||||
a.class("w-24 h-24 rounded-full"),
|
||||
]),
|
||||
],
|
||||
),
|
||||
])
|
||||
},
|
||||
ui.detail_header(guild_data.name, [
|
||||
#(
|
||||
"Guild ID:",
|
||||
h.div([a.class("text-sm text-neutral-900")], [
|
||||
element.text(guild_data.id),
|
||||
]),
|
||||
),
|
||||
#(
|
||||
"Owner ID:",
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users/" <> guild_data.owner_id),
|
||||
a.class(
|
||||
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
|
||||
),
|
||||
],
|
||||
[element.text(guild_data.owner_id)],
|
||||
)
|
||||
option.None ->
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600",
|
||||
),
|
||||
],
|
||||
[
|
||||
element.text(avatar.get_initials_from_name(
|
||||
guild_data.name,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
},
|
||||
ui.detail_header(guild_data.name, [
|
||||
#(
|
||||
"Guild ID:",
|
||||
h.div([a.class("text-sm text-neutral-900 break-all")], [
|
||||
element.text(guild_data.id),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
#(
|
||||
"Owner ID:",
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users/" <> guild_data.owner_id),
|
||||
a.class(
|
||||
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
|
||||
),
|
||||
],
|
||||
[element.text(guild_data.owner_id)],
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
render_tabs(
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn view(
|
||||
fn render_search_form(ctx: Context, query: option.Option(String)) {
|
||||
ui.card(ui.PaddingSmall, [
|
||||
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
||||
h.div([a.class("flex gap-2")], [
|
||||
h.div([a.class("flex flex-col sm:flex-row gap-2")], [
|
||||
h.input([
|
||||
a.type_("text"),
|
||||
a.name("q"),
|
||||
@@ -123,12 +123,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
|
||||
),
|
||||
a.attribute("autocomplete", "off"),
|
||||
]),
|
||||
ui.button_primary("Search", "submit", []),
|
||||
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/guilds"),
|
||||
a.class(
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
|
||||
),
|
||||
],
|
||||
[element.text("Clear")],
|
||||
@@ -159,7 +159,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
|
||||
],
|
||||
[
|
||||
h.div([a.class("p-5")], [
|
||||
h.div([a.class("flex items-center gap-4")], [
|
||||
h.div([a.class("flex flex-col sm:flex-row sm:items-center gap-4")], [
|
||||
case
|
||||
avatar.get_guild_icon_url(
|
||||
ctx.media_endpoint,
|
||||
@@ -169,27 +169,41 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
|
||||
)
|
||||
{
|
||||
option.Some(icon_url) ->
|
||||
h.div([a.class("flex-shrink-0")], [
|
||||
h.img([
|
||||
a.src(icon_url),
|
||||
a.alt(guild.name),
|
||||
a.class("w-16 h-16 rounded-full"),
|
||||
]),
|
||||
])
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.img([
|
||||
a.src(icon_url),
|
||||
a.alt(guild.name),
|
||||
a.class("w-16 h-16 rounded-full"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
option.None ->
|
||||
h.div([a.class("flex-shrink-0")], [
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600",
|
||||
),
|
||||
],
|
||||
[element.text(avatar.get_initials_from_name(guild.name))],
|
||||
),
|
||||
])
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600",
|
||||
),
|
||||
],
|
||||
[element.text(avatar.get_initials_from_name(guild.name))],
|
||||
),
|
||||
],
|
||||
)
|
||||
},
|
||||
h.div([a.class("flex-1 min-w-0")], [
|
||||
h.div([a.class("flex items-center gap-2 mb-2")], [
|
||||
h.div([a.class("flex items-center gap-2 mb-2 flex-wrap")], [
|
||||
h.h2([a.class("text-base font-medium text-neutral-900")], [
|
||||
element.text(guild.name),
|
||||
]),
|
||||
@@ -207,7 +221,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
|
||||
},
|
||||
]),
|
||||
h.div([a.class("space-y-0.5")], [
|
||||
h.div([a.class("text-sm text-neutral-600")], [
|
||||
h.div([a.class("text-sm text-neutral-600 break-all")], [
|
||||
element.text("ID: " <> guild.id),
|
||||
]),
|
||||
h.div([a.class("text-sm text-neutral-600")], [
|
||||
|
||||
@@ -15,18 +15,16 @@
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_admin/acl
|
||||
import fluxer_admin/api/common
|
||||
import fluxer_admin/api/verifications
|
||||
import fluxer_admin/avatar
|
||||
import fluxer_admin/components/flash
|
||||
import fluxer_admin/components/layout
|
||||
import fluxer_admin/components/review_deck
|
||||
import fluxer_admin/components/review_hintbar
|
||||
import fluxer_admin/components/ui
|
||||
import fluxer_admin/constants
|
||||
import fluxer_admin/user
|
||||
import fluxer_admin/web.{
|
||||
type Context, type Session, action, href, prepend_base_path,
|
||||
}
|
||||
import fluxer_admin/web.{type Context, type Session, action, href}
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/option
|
||||
@@ -47,6 +45,60 @@ const suspicious_user_agent_keywords = [
|
||||
"go-http-client",
|
||||
]
|
||||
|
||||
fn selection_toolbar() -> element.Element(a) {
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"mt-4 flex items-center justify-between gap-3 bg-neutral-50 border border-neutral-200 rounded-lg px-3 py-2",
|
||||
),
|
||||
a.attribute("data-selection-toolbar", "true"),
|
||||
],
|
||||
[
|
||||
h.div([a.class("flex items-center gap-3")], [
|
||||
h.input([
|
||||
a.type_("checkbox"),
|
||||
a.attribute("data-select-all", "true"),
|
||||
a.class("h-4 w-4 rounded border-neutral-300"),
|
||||
]),
|
||||
h.span([a.class("text-sm text-neutral-700")], [
|
||||
element.text("Select all visible"),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex items-center gap-3 flex-wrap justify-end")], [
|
||||
h.span(
|
||||
[
|
||||
a.attribute("data-selected-count", "true"),
|
||||
a.class("text-sm text-neutral-600"),
|
||||
],
|
||||
[element.text("0 selected")],
|
||||
),
|
||||
h.div([a.class("flex items-center gap-2")], [
|
||||
h.button(
|
||||
[
|
||||
a.attribute("data-bulk-action", "reject"),
|
||||
a.class(
|
||||
"px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
),
|
||||
a.disabled(True),
|
||||
],
|
||||
[element.text("Reject selected")],
|
||||
),
|
||||
h.button(
|
||||
[
|
||||
a.attribute("data-bulk-action", "approve"),
|
||||
a.class(
|
||||
"px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
),
|
||||
a.disabled(True),
|
||||
],
|
||||
[element.text("Approve selected")],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn view(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
@@ -55,6 +107,12 @@ pub fn view(
|
||||
) -> Response {
|
||||
let limit = 50
|
||||
let result = verifications.list_pending_verifications(ctx, session, limit)
|
||||
let admin_acls = case current_admin {
|
||||
option.Some(admin) -> admin.acls
|
||||
option.None -> []
|
||||
}
|
||||
let can_review =
|
||||
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
|
||||
|
||||
let content = case result {
|
||||
Ok(response) -> {
|
||||
@@ -75,72 +133,31 @@ pub fn view(
|
||||
h.span(
|
||||
[
|
||||
a.class("body-sm text-neutral-600"),
|
||||
a.attribute("data-review-progress", ""),
|
||||
],
|
||||
[
|
||||
element.text(int.to_string(count) <> " remaining"),
|
||||
a.attribute("data-remaining-total", "true"),
|
||||
],
|
||||
[element.text(int.to_string(count) <> " remaining")],
|
||||
)
|
||||
},
|
||||
]),
|
||||
]),
|
||||
case can_review {
|
||||
True -> selection_toolbar()
|
||||
False -> element.none()
|
||||
},
|
||||
case list.is_empty(response.pending_verifications) {
|
||||
True -> empty_state()
|
||||
False ->
|
||||
h.div(
|
||||
[a.class("mt-4")],
|
||||
list.append(
|
||||
[review_deck.styles()],
|
||||
list.append(review_deck.script_tags(), [
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-review-deck", "true"),
|
||||
a.attribute(
|
||||
"data-fragment-base",
|
||||
prepend_base_path(
|
||||
ctx,
|
||||
"/pending-verifications/fragment",
|
||||
),
|
||||
),
|
||||
a.attribute("data-next-page", "2"),
|
||||
a.attribute("data-can-paginate", "true"),
|
||||
a.attribute("data-prefetch-when-remaining", "6"),
|
||||
a.attribute(
|
||||
"data-empty-url",
|
||||
prepend_base_path(ctx, "/pending-verifications"),
|
||||
),
|
||||
a.tabindex(0),
|
||||
],
|
||||
[
|
||||
h.div(
|
||||
[a.class("max-w-2xl mx-auto")],
|
||||
list.map(response.pending_verifications, fn(pv) {
|
||||
render_pending_verification_card(ctx, pv)
|
||||
}),
|
||||
),
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-review-progress", "true"),
|
||||
a.class("text-center mt-4 body-sm text-neutral-600"),
|
||||
],
|
||||
[
|
||||
element.text(int.to_string(total) <> " remaining"),
|
||||
],
|
||||
),
|
||||
review_hintbar.view(
|
||||
"←",
|
||||
"Reject",
|
||||
"→",
|
||||
"Approve",
|
||||
"Esc",
|
||||
"Exit",
|
||||
option.Some("Swipe cards on touch devices"),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
h.div([a.class("mt-4")], [
|
||||
h.div(
|
||||
[
|
||||
a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3"),
|
||||
a.attribute("data-select-grid", "true"),
|
||||
],
|
||||
list.map(response.pending_verifications, fn(pv) {
|
||||
render_pending_verification_card(ctx, pv, can_review)
|
||||
}),
|
||||
),
|
||||
)
|
||||
])
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -156,12 +173,17 @@ pub fn view(
|
||||
session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
content,
|
||||
h.div([], [content, pending_verifications_script()]),
|
||||
)
|
||||
wisp.html_response(element.to_document_string(html), 200)
|
||||
}
|
||||
|
||||
pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
|
||||
pub fn view_fragment(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
page: Int,
|
||||
can_review: Bool,
|
||||
) -> Response {
|
||||
let limit = 50
|
||||
let _offset = page * limit
|
||||
let result = verifications.list_pending_verifications(ctx, session, limit)
|
||||
@@ -170,19 +192,17 @@ pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
|
||||
Ok(response) -> {
|
||||
let fragment =
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-review-fragment", "true"),
|
||||
a.attribute("data-page", int.to_string(page)),
|
||||
],
|
||||
[a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
|
||||
list.map(response.pending_verifications, fn(pv) {
|
||||
render_pending_verification_card(ctx, pv)
|
||||
render_pending_verification_card(ctx, pv, can_review)
|
||||
}),
|
||||
)
|
||||
|
||||
wisp.html_response(element.to_document_string(fragment), 200)
|
||||
}
|
||||
Error(_) -> {
|
||||
let empty = h.div([a.attribute("data-review-fragment", "true")], [])
|
||||
let empty =
|
||||
h.div([a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")], [])
|
||||
|
||||
wisp.html_response(element.to_document_string(empty), 200)
|
||||
}
|
||||
@@ -198,6 +218,12 @@ pub fn view_single(
|
||||
) -> Response {
|
||||
let limit = 50
|
||||
let result = verifications.list_pending_verifications(ctx, session, limit)
|
||||
let admin_acls = case current_admin {
|
||||
option.Some(admin) -> admin.acls
|
||||
option.None -> []
|
||||
}
|
||||
let can_review =
|
||||
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
|
||||
|
||||
let content = case result {
|
||||
Ok(response) -> {
|
||||
@@ -224,45 +250,13 @@ pub fn view_single(
|
||||
),
|
||||
]),
|
||||
h.div(
|
||||
[a.class("mt-4")],
|
||||
list.append(
|
||||
[review_deck.styles()],
|
||||
list.append(review_deck.script_tags(), [
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-review-deck", "true"),
|
||||
a.attribute(
|
||||
"data-empty-url",
|
||||
prepend_base_path(ctx, "/pending-verifications"),
|
||||
),
|
||||
a.tabindex(0),
|
||||
],
|
||||
[
|
||||
h.div([a.class("max-w-2xl mx-auto")], [
|
||||
render_pending_verification_card(ctx, pv),
|
||||
]),
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-review-progress", "true"),
|
||||
a.class("text-center mt-4 body-sm text-neutral-600"),
|
||||
],
|
||||
[
|
||||
element.text("1 remaining"),
|
||||
],
|
||||
),
|
||||
review_hintbar.view(
|
||||
"←",
|
||||
"Reject",
|
||||
"→",
|
||||
"Approve",
|
||||
"Esc",
|
||||
"Exit",
|
||||
option.Some("Swipe cards on touch devices"),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
[
|
||||
a.class("mt-4 max-w-3xl"),
|
||||
a.attribute("data-select-grid", "true"),
|
||||
],
|
||||
[
|
||||
render_pending_verification_card(ctx, pv, can_review),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -289,7 +283,7 @@ pub fn view_single(
|
||||
session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
content,
|
||||
h.div([], [content, pending_verifications_script()]),
|
||||
)
|
||||
wisp.html_response(element.to_document_string(html), 200)
|
||||
}
|
||||
@@ -432,23 +426,30 @@ fn api_error_message(err: common.ApiError) -> String {
|
||||
fn render_pending_verification_card(
|
||||
ctx: Context,
|
||||
pv: verifications.PendingVerification,
|
||||
can_review: Bool,
|
||||
) -> element.Element(a) {
|
||||
let metadata_warning = user_agent_warning(pv.metadata)
|
||||
let geoip_hint = geoip_reason_value(pv.metadata)
|
||||
|
||||
h.div(
|
||||
[
|
||||
a.attribute("data-review-card", "true"),
|
||||
a.attribute(
|
||||
"data-direct-url",
|
||||
prepend_base_path(ctx, "/pending-verifications/" <> pv.user_id),
|
||||
),
|
||||
a.class(
|
||||
"bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900",
|
||||
),
|
||||
a.tabindex(0),
|
||||
a.attribute("data-select-card", pv.user_id),
|
||||
],
|
||||
[
|
||||
h.div([a.class("flex items-start gap-4 mb-6")], [
|
||||
case can_review {
|
||||
True ->
|
||||
h.input([
|
||||
a.type_("checkbox"),
|
||||
a.class("h-4 w-4 mt-1.5 rounded border-neutral-300"),
|
||||
a.attribute("data-select-checkbox", pv.user_id),
|
||||
])
|
||||
False -> element.none()
|
||||
},
|
||||
h.img([
|
||||
a.src(avatar.get_user_avatar_url(
|
||||
ctx.media_endpoint,
|
||||
@@ -487,6 +488,16 @@ fn render_pending_verification_card(
|
||||
element.text("Registered " <> format_timestamp(pv.created_at)),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex flex-col items-end gap-2 ml-auto")], [
|
||||
case metadata_warning {
|
||||
option.Some(msg) -> ui.pill(msg, ui.PillWarning)
|
||||
option.None -> element.none()
|
||||
},
|
||||
case geoip_hint {
|
||||
option.Some(hint) -> ui.pill("GeoIP: " <> hint, ui.PillInfo)
|
||||
option.None -> element.none()
|
||||
},
|
||||
]),
|
||||
]),
|
||||
h.details(
|
||||
[
|
||||
@@ -515,63 +526,68 @@ fn render_pending_verification_card(
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.form(
|
||||
[
|
||||
a.method("post"),
|
||||
action(ctx, "/pending-verifications?action=reject"),
|
||||
a.attribute("data-review-submit", "left"),
|
||||
a.class("inline-flex w-full"),
|
||||
],
|
||||
[
|
||||
h.input([
|
||||
a.type_("hidden"),
|
||||
a.name("user_id"),
|
||||
a.value(pv.user_id),
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.form(
|
||||
[
|
||||
a.method("post"),
|
||||
action(ctx, "/pending-verifications?action=approve"),
|
||||
a.attribute("data-review-submit", "right"),
|
||||
a.class("inline-flex w-full"),
|
||||
],
|
||||
[
|
||||
h.input([
|
||||
a.type_("hidden"),
|
||||
a.name("user_id"),
|
||||
a.value(pv.user_id),
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex items-center justify-between gap-4 pt-4 border-t border-neutral-200",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.button(
|
||||
[
|
||||
a.attribute("data-review-action", "left"),
|
||||
a.class(
|
||||
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
|
||||
case can_review {
|
||||
True ->
|
||||
h.div([a.class("pt-4 border-t border-neutral-200")], [
|
||||
h.div([a.class("flex items-center justify-end gap-2 flex-wrap")], [
|
||||
h.form(
|
||||
[
|
||||
a.method("post"),
|
||||
action(ctx, "/pending-verifications?action=reject"),
|
||||
a.attribute("data-async", "true"),
|
||||
a.attribute("data-confirm", "Reject this registration?"),
|
||||
],
|
||||
[
|
||||
h.input([
|
||||
a.type_("hidden"),
|
||||
a.name("user_id"),
|
||||
a.value(pv.user_id),
|
||||
]),
|
||||
h.button(
|
||||
[
|
||||
a.type_("submit"),
|
||||
a.attribute("data-action-type", "reject"),
|
||||
a.attribute("accesskey", "r"),
|
||||
a.class(
|
||||
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Reject")],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
[element.text("Reject")],
|
||||
),
|
||||
h.button(
|
||||
[
|
||||
a.attribute("data-review-action", "right"),
|
||||
a.class(
|
||||
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors",
|
||||
h.form(
|
||||
[
|
||||
a.method("post"),
|
||||
action(ctx, "/pending-verifications?action=approve"),
|
||||
a.attribute("data-async", "true"),
|
||||
],
|
||||
[
|
||||
h.input([
|
||||
a.type_("hidden"),
|
||||
a.name("user_id"),
|
||||
a.value(pv.user_id),
|
||||
]),
|
||||
h.button(
|
||||
[
|
||||
a.type_("submit"),
|
||||
a.attribute("data-action-type", "approve"),
|
||||
a.attribute("accesskey", "a"),
|
||||
a.class(
|
||||
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors",
|
||||
),
|
||||
],
|
||||
[element.text("Approve")],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
[element.text("Approve")],
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
])
|
||||
False ->
|
||||
h.div([a.class("text-sm text-neutral-500 pt-2")], [
|
||||
element.text("You need pending_verification:review to act"),
|
||||
])
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -689,6 +705,19 @@ fn metadata_value(
|
||||
})
|
||||
}
|
||||
|
||||
fn geoip_reason_value(
|
||||
metadata: List(verifications.PendingVerificationMetadata),
|
||||
) -> option.Option(String) {
|
||||
case metadata_value(metadata, "geoip_reason") {
|
||||
option.Some(reason) ->
|
||||
case reason {
|
||||
"none" -> option.None
|
||||
r -> option.Some(r)
|
||||
}
|
||||
option.None -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
fn option_or_default(default: String, value: option.Option(String)) -> String {
|
||||
case value {
|
||||
option.Some(v) -> v
|
||||
@@ -737,6 +766,190 @@ fn empty_state() {
|
||||
])
|
||||
}
|
||||
|
||||
fn pending_verifications_script() -> element.Element(a) {
|
||||
let js =
|
||||
"
|
||||
(function () {
|
||||
const grid = document.querySelector('[data-select-grid]');
|
||||
if (!grid) return;
|
||||
const toolbar = document.querySelector('[data-selection-toolbar]');
|
||||
const remainingEl = document.querySelector('[data-remaining-total]');
|
||||
|
||||
const countEl = toolbar?.querySelector('[data-selected-count]') || null;
|
||||
const selectAll = toolbar?.querySelector('[data-select-all]') || null;
|
||||
const bulkButtons = toolbar
|
||||
? Array.from(toolbar.querySelectorAll('[data-bulk-action]'))
|
||||
: [];
|
||||
|
||||
function showToast(message, variant) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'fixed left-4 right-4 bottom-4 z-50';
|
||||
box.innerHTML =
|
||||
'<div class=\"max-w-xl mx-auto\">' +
|
||||
'<div class=\"px-4 py-3 rounded-lg shadow border ' +
|
||||
(variant === 'success'
|
||||
? 'bg-green-50 border-green-200 text-green-800'
|
||||
: 'bg-red-50 border-red-200 text-red-800') +
|
||||
'\">' +
|
||||
'<div class=\"text-sm font-semibold\">' +
|
||||
(variant === 'success' ? 'Saved' : 'Action failed') +
|
||||
'</div>' +
|
||||
'<div class=\"text-sm mt-1 break-words\">' + (message || 'OK') + '</div>' +
|
||||
'</div></div>';
|
||||
document.body.appendChild(box);
|
||||
setTimeout(() => box.remove(), 4200);
|
||||
}
|
||||
|
||||
function updateRemaining() {
|
||||
if (!remainingEl) return;
|
||||
const total = grid.querySelectorAll('[data-select-card]').length;
|
||||
remainingEl.textContent = total.toString() + ' remaining';
|
||||
}
|
||||
|
||||
function updateSelection() {
|
||||
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]'));
|
||||
const selected = boxes.filter((b) => b.checked);
|
||||
if (countEl) countEl.textContent = selected.length + ' selected';
|
||||
bulkButtons.forEach((btn) => (btn.disabled = selected.length === 0));
|
||||
if (selectAll) {
|
||||
selectAll.checked = selected.length > 0 && selected.length === boxes.length;
|
||||
selectAll.indeterminate =
|
||||
selected.length > 0 && selected.length < boxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function cardFor(id) {
|
||||
return grid.querySelector('[data-select-card=\"' + id + '\"]');
|
||||
}
|
||||
|
||||
function removeCard(id) {
|
||||
const card = cardFor(id);
|
||||
if (card) card.remove();
|
||||
updateRemaining();
|
||||
updateSelection();
|
||||
if (grid.querySelectorAll('[data-select-card]').length === 0) {
|
||||
grid.innerHTML =
|
||||
'<div class=\"col-span-full border border-dashed border-neutral-200 rounded-lg p-8 text-center text-neutral-500\">All registration requests have been processed</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function setButtonLoading(btn, loading) {
|
||||
if (!btn) return;
|
||||
btn.disabled = loading;
|
||||
if (loading) {
|
||||
btn.dataset.originalText = btn.textContent;
|
||||
btn.textContent = 'Working…';
|
||||
} else if (btn.dataset.originalText) {
|
||||
btn.textContent = btn.dataset.originalText;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(form) {
|
||||
const actionUrl = new URL(form.action, window.location.origin);
|
||||
actionUrl.searchParams.set('background', '1');
|
||||
const fd = new FormData(form);
|
||||
const body = new URLSearchParams();
|
||||
fd.forEach((v, k) => body.append(k, v));
|
||||
const resp = await fetch(actionUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
let txt = '';
|
||||
try { txt = await resp.text(); } catch (_) {}
|
||||
throw new Error(txt || 'Request failed (' + resp.status + ')');
|
||||
}
|
||||
}
|
||||
|
||||
async function actOn(id, action) {
|
||||
const form = grid.querySelector(
|
||||
'[data-select-card=\"' + id + '\"] form[data-action-type=\"' + action + '\"]'
|
||||
);
|
||||
if (!form) throw new Error('Missing form for ' + action);
|
||||
await submitForm(form);
|
||||
removeCard(id);
|
||||
}
|
||||
|
||||
async function handleBulk(action) {
|
||||
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]:checked'));
|
||||
if (boxes.length === 0) return;
|
||||
const confirmMsg =
|
||||
action === 'reject'
|
||||
? 'Reject ' + boxes.length + ' registration(s)?'
|
||||
: 'Approve ' + boxes.length + ' registration(s)?';
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
const button = toolbar.querySelector('[data-bulk-action=\"' + action + '\"]');
|
||||
setButtonLoading(button, true);
|
||||
try {
|
||||
for (const box of boxes) {
|
||||
const id = box.getAttribute('data-select-checkbox');
|
||||
if (!id) continue;
|
||||
await actOn(id, action);
|
||||
}
|
||||
showToast('Completed ' + action + ' for ' + boxes.length + ' item(s)', 'success');
|
||||
} catch (err) {
|
||||
showToast(err && err.message ? err.message : String(err), 'error');
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
}
|
||||
|
||||
bulkButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const action = btn.getAttribute('data-bulk-action');
|
||||
if (action) handleBulk(action);
|
||||
});
|
||||
});
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', (e) => {
|
||||
const boxes = grid.querySelectorAll('[data-select-checkbox]');
|
||||
boxes.forEach((b) => (b.checked = e.target.checked));
|
||||
updateSelection();
|
||||
});
|
||||
}
|
||||
|
||||
grid.addEventListener('change', (e) => {
|
||||
const target = e.target;
|
||||
if (target && target.matches('[data-select-checkbox]')) {
|
||||
updateSelection();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('form[data-async]').forEach((form) => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const confirmMsg = form.getAttribute('data-confirm');
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
const btn = form.querySelector('button[type=\"submit\"]');
|
||||
const id = form.querySelector('[name=\"user_id\"]')?.value;
|
||||
const action = btn?.getAttribute('data-action-type') || 'action';
|
||||
setButtonLoading(btn, true);
|
||||
submitForm(form)
|
||||
.then(() => {
|
||||
if (id) removeCard(id);
|
||||
showToast(
|
||||
(action === 'approve' ? 'Approved ' : 'Rejected ') + (id || 'item'),
|
||||
'success'
|
||||
);
|
||||
})
|
||||
.catch((err) => showToast(err && err.message ? err.message : String(err), 'error'))
|
||||
.finally(() => setButtonLoading(btn, false));
|
||||
});
|
||||
});
|
||||
|
||||
updateRemaining();
|
||||
updateSelection();
|
||||
})();
|
||||
"
|
||||
|
||||
h.script([a.attribute("defer", "defer")], js)
|
||||
}
|
||||
|
||||
fn error_view(err: common.ApiError) {
|
||||
let #(title, message) = case err {
|
||||
common.Unauthorized -> #(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -121,85 +121,99 @@ pub fn view(
|
||||
h.div(
|
||||
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
|
||||
[
|
||||
h.div([a.class("flex items-start gap-6")], [
|
||||
h.div([a.class("flex-shrink-0")], [
|
||||
h.img([
|
||||
a.src(avatar.get_user_avatar_url(
|
||||
ctx.media_endpoint,
|
||||
ctx.cdn_endpoint,
|
||||
user_data.id,
|
||||
user_data.avatar,
|
||||
True,
|
||||
ctx.asset_version,
|
||||
)),
|
||||
a.alt(user_data.username),
|
||||
a.class("w-24 h-24 rounded-full"),
|
||||
]),
|
||||
]),
|
||||
h.div([a.class("flex-1")], [
|
||||
h.div([a.class("flex items-center gap-3 mb-3")], [
|
||||
ui.heading_section(
|
||||
user_data.username
|
||||
<> "#"
|
||||
<> user.format_discriminator(user_data.discriminator),
|
||||
),
|
||||
case user_data.bot {
|
||||
True ->
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase",
|
||||
),
|
||||
],
|
||||
[element.text("Bot")],
|
||||
)
|
||||
False -> element.none()
|
||||
},
|
||||
]),
|
||||
case list.is_empty(badges) {
|
||||
False ->
|
||||
h.div(
|
||||
[a.class("flex items-center gap-2 mb-3")],
|
||||
list.map(badges, fn(b) {
|
||||
h.img([
|
||||
a.src(b.icon),
|
||||
a.alt(b.name),
|
||||
a.title(b.name),
|
||||
a.class("w-6 h-6"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
True -> element.none()
|
||||
},
|
||||
h.div([a.class("flex flex-wrap items-start gap-4")], [
|
||||
h.div([a.class("flex items-start gap-2")], [
|
||||
h.div([a.class("text-sm font-medium text-neutral-600")], [
|
||||
element.text("User ID:"),
|
||||
]),
|
||||
h.div([a.class("text-sm text-neutral-900")], [
|
||||
element.text(user_data.id),
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.div(
|
||||
[
|
||||
a.class(
|
||||
"flex-shrink-0 flex items-center sm:block justify-center",
|
||||
),
|
||||
],
|
||||
[
|
||||
h.img([
|
||||
a.src(avatar.get_user_avatar_url(
|
||||
ctx.media_endpoint,
|
||||
ctx.cdn_endpoint,
|
||||
user_data.id,
|
||||
user_data.avatar,
|
||||
True,
|
||||
ctx.asset_version,
|
||||
)),
|
||||
a.alt(user_data.username),
|
||||
a.class("w-24 h-24 rounded-full"),
|
||||
]),
|
||||
],
|
||||
),
|
||||
h.div([a.class("flex-1")], [
|
||||
h.div([a.class("flex flex-wrap items-center gap-3 mb-3")], [
|
||||
ui.heading_section(
|
||||
user_data.username
|
||||
<> "#"
|
||||
<> user.format_discriminator(user_data.discriminator),
|
||||
),
|
||||
case user_data.bot {
|
||||
True ->
|
||||
h.span(
|
||||
[
|
||||
a.class(
|
||||
"px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase",
|
||||
),
|
||||
],
|
||||
[element.text("Bot")],
|
||||
)
|
||||
False -> element.none()
|
||||
},
|
||||
]),
|
||||
case user.extract_timestamp(user_data.id) {
|
||||
Ok(created_at) ->
|
||||
h.div([a.class("flex items-start gap-2")], [
|
||||
h.div(
|
||||
[
|
||||
a.class("text-sm font-medium text-neutral-600"),
|
||||
],
|
||||
[
|
||||
element.text("Created:"),
|
||||
],
|
||||
),
|
||||
h.div([a.class("text-sm text-neutral-900")], [
|
||||
element.text(created_at),
|
||||
]),
|
||||
])
|
||||
Error(_) -> element.none()
|
||||
case list.is_empty(badges) {
|
||||
False ->
|
||||
h.div(
|
||||
[a.class("flex items-center gap-2 mb-3 flex-wrap")],
|
||||
list.map(badges, fn(b) {
|
||||
h.img([
|
||||
a.src(b.icon),
|
||||
a.alt(b.name),
|
||||
a.title(b.name),
|
||||
a.class("w-6 h-6"),
|
||||
])
|
||||
}),
|
||||
)
|
||||
True -> element.none()
|
||||
},
|
||||
h.div([a.class("flex flex-wrap items-start gap-4")], [
|
||||
h.div([a.class("flex items-start gap-2 min-w-0")], [
|
||||
h.div([a.class("text-sm font-medium text-neutral-600")], [
|
||||
element.text("User ID:"),
|
||||
]),
|
||||
h.div([a.class("text-sm text-neutral-900 break-all")], [
|
||||
element.text(user_data.id),
|
||||
]),
|
||||
]),
|
||||
case user.extract_timestamp(user_data.id) {
|
||||
Ok(created_at) ->
|
||||
h.div([a.class("flex items-start gap-2")], [
|
||||
h.div(
|
||||
[
|
||||
a.class("text-sm font-medium text-neutral-600"),
|
||||
],
|
||||
[
|
||||
element.text("Created:"),
|
||||
],
|
||||
),
|
||||
h.div([a.class("text-sm text-neutral-900")], [
|
||||
element.text(created_at),
|
||||
]),
|
||||
])
|
||||
Error(_) -> element.none()
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
render_tabs(
|
||||
|
||||
@@ -114,7 +114,7 @@ pub fn view(
|
||||
fn render_search_form(ctx: Context, query: option.Option(String)) {
|
||||
ui.card(ui.PaddingSmall, [
|
||||
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
|
||||
h.div([a.class("flex gap-2")], [
|
||||
h.div([a.class("flex flex-col sm:flex-row gap-2")], [
|
||||
h.input([
|
||||
a.type_("text"),
|
||||
a.name("q"),
|
||||
@@ -125,12 +125,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
|
||||
),
|
||||
a.attribute("autocomplete", "off"),
|
||||
]),
|
||||
ui.button_primary("Search", "submit", []),
|
||||
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
|
||||
h.a(
|
||||
[
|
||||
href(ctx, "/users"),
|
||||
a.class(
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
|
||||
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
|
||||
),
|
||||
],
|
||||
[element.text("Clear")],
|
||||
|
||||
@@ -161,6 +161,19 @@ fn get_bool_query(req: Request, key: String) -> Bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_limit(limit: Int) -> Int {
|
||||
let min = 10
|
||||
let max = 200
|
||||
case limit < min {
|
||||
True -> min
|
||||
False ->
|
||||
case limit > max {
|
||||
True -> max
|
||||
False -> limit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn api_error_message(err: common.ApiError) -> String {
|
||||
case err {
|
||||
common.Unauthorized -> "Unauthorized"
|
||||
@@ -897,27 +910,71 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|
||||
Get -> {
|
||||
use user_session, current_admin <- with_session_and_admin(req, ctx)
|
||||
let flash_data = flash.from_request(req)
|
||||
let admin_acls = case current_admin {
|
||||
option.Some(admin) -> admin.acls
|
||||
_ -> []
|
||||
}
|
||||
|
||||
pending_verifications_page.view(
|
||||
ctx,
|
||||
user_session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
)
|
||||
case
|
||||
acl.has_permission(
|
||||
admin_acls,
|
||||
constants.acl_pending_verification_view,
|
||||
)
|
||||
{
|
||||
True ->
|
||||
pending_verifications_page.view(
|
||||
ctx,
|
||||
user_session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
)
|
||||
False ->
|
||||
wisp.response(403)
|
||||
|> wisp.string_body(
|
||||
"Forbidden: requires pending_verification:view permission",
|
||||
)
|
||||
}
|
||||
}
|
||||
Post -> {
|
||||
use user_session <- with_session(req, ctx)
|
||||
let query = wisp.get_query(req)
|
||||
let action = list.key_find(query, "action") |> option.from_result
|
||||
let background = get_bool_query(req, "background")
|
||||
let admin_result = users.get_current_admin(ctx, user_session)
|
||||
let admin_acls = case admin_result {
|
||||
Ok(option.Some(admin)) -> admin.acls
|
||||
_ -> []
|
||||
}
|
||||
|
||||
pending_verifications_page.handle_action(
|
||||
req,
|
||||
ctx,
|
||||
user_session,
|
||||
action,
|
||||
background,
|
||||
)
|
||||
case
|
||||
acl.has_permission(
|
||||
admin_acls,
|
||||
constants.acl_pending_verification_review,
|
||||
)
|
||||
{
|
||||
True ->
|
||||
pending_verifications_page.handle_action(
|
||||
req,
|
||||
ctx,
|
||||
user_session,
|
||||
action,
|
||||
background,
|
||||
)
|
||||
False ->
|
||||
case background {
|
||||
True ->
|
||||
wisp.json_response(
|
||||
"{\"error\": \"Forbidden: requires pending_verification:review permission\"}",
|
||||
403,
|
||||
)
|
||||
False ->
|
||||
flash.redirect_with_error(
|
||||
ctx,
|
||||
"/pending-verifications",
|
||||
"Forbidden: requires pending_verification:review permission",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ -> wisp.method_not_allowed([Get, Post])
|
||||
}
|
||||
@@ -932,8 +989,32 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|
||||
|> option.unwrap("1")
|
||||
let page =
|
||||
int.parse(page_str) |> option.from_result |> option.unwrap(1)
|
||||
let admin_result = users.get_current_admin(ctx, user_session)
|
||||
let admin_acls = case admin_result {
|
||||
Ok(option.Some(admin)) -> admin.acls
|
||||
_ -> []
|
||||
}
|
||||
let can_review =
|
||||
acl.has_permission(
|
||||
admin_acls,
|
||||
constants.acl_pending_verification_review,
|
||||
)
|
||||
|
||||
pending_verifications_page.view_fragment(ctx, user_session, page)
|
||||
case
|
||||
acl.has_permission(
|
||||
admin_acls,
|
||||
constants.acl_pending_verification_view,
|
||||
)
|
||||
{
|
||||
True ->
|
||||
pending_verifications_page.view_fragment(
|
||||
ctx,
|
||||
user_session,
|
||||
page,
|
||||
can_review,
|
||||
)
|
||||
False -> wisp.response(403) |> wisp.string_body("Forbidden")
|
||||
}
|
||||
}
|
||||
_ -> wisp.method_not_allowed([Get])
|
||||
}
|
||||
@@ -985,34 +1066,39 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|
||||
|> option.unwrap("0")
|
||||
let page =
|
||||
int.parse(page_str) |> option.from_result |> option.unwrap(0)
|
||||
let fragment = get_bool_query(req, "fragment")
|
||||
let table_view = get_bool_query(req, "view")
|
||||
let sort =
|
||||
list.key_find(query, "sort")
|
||||
|> option.from_result
|
||||
|> option.map(fn(s) {
|
||||
case string.trim(s) {
|
||||
"" -> option.None
|
||||
v -> option.Some(v)
|
||||
}
|
||||
})
|
||||
|> option.unwrap(option.None)
|
||||
let limit_str =
|
||||
list.key_find(query, "limit")
|
||||
|> option.from_result
|
||||
|> option.unwrap("50")
|
||||
let limit =
|
||||
int.parse(limit_str)
|
||||
|> option.from_result
|
||||
|> option.unwrap(50)
|
||||
|> clamp_limit
|
||||
|
||||
case fragment {
|
||||
True ->
|
||||
reports_page.view_fragment(
|
||||
ctx,
|
||||
user_session,
|
||||
search_query,
|
||||
status_filter,
|
||||
type_filter,
|
||||
category_filter,
|
||||
page,
|
||||
)
|
||||
False ->
|
||||
reports_page.view_with_mode(
|
||||
ctx,
|
||||
user_session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
search_query,
|
||||
status_filter,
|
||||
type_filter,
|
||||
category_filter,
|
||||
page,
|
||||
table_view,
|
||||
)
|
||||
}
|
||||
reports_page.view_with_mode(
|
||||
ctx,
|
||||
user_session,
|
||||
current_admin,
|
||||
flash_data,
|
||||
search_query,
|
||||
status_filter,
|
||||
type_filter,
|
||||
category_filter,
|
||||
page,
|
||||
limit,
|
||||
sort,
|
||||
)
|
||||
}
|
||||
_ -> wisp.method_not_allowed([Get])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user