From 25228df6d13b5e8541672c4cdd84e200ff56a4c4 Mon Sep 17 00:00:00 2001 From: Nathan Reiner Date: Wed, 19 Nov 2025 18:58:54 +0100 Subject: add profile settings to backend and add image loader --- src/context.zig | 1 - src/routes/api/auth/login.zig | 2 +- src/routes/api/profile/root.zig | 4 +++ src/routes/api/profile/set.zig | 26 ++++++++++++++++ src/routes/handler-info.zig | 9 ++++-- src/storage/user.zig | 24 ++++++++++++--- static/api/profile.js | 7 +++++ static/api/rest.js | 2 +- static/index.js | 2 +- static/pages/image-viewer/index.css | 2 +- static/pages/image-viewer/index.js | 15 +++++----- static/pages/settings/index.js | 8 ++++- static/widgets/editable/index.js | 11 +++++++ static/widgets/image/index.css | 59 +++++++++++++++++++++++++++++++++++++ static/widgets/image/index.js | 30 +++++++++++++++++++ 15 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 src/routes/api/profile/set.zig create mode 100644 static/widgets/image/index.css create mode 100644 static/widgets/image/index.js diff --git a/src/context.zig b/src/context.zig index 54ece18..6e73b5d 100644 --- a/src/context.zig +++ b/src/context.zig @@ -9,7 +9,6 @@ allocator: std.mem.Allocator, request: *std.http.Server.Request, storage: *Storage, fingerprint: []const u8, -user: ?[]const u8 = null, response: struct { headers: struct { content_type: []const u8 = "application/json", diff --git a/src/routes/api/auth/login.zig b/src/routes/api/auth/login.zig index d7ee5cb..8a33082 100644 --- a/src/routes/api/auth/login.zig +++ b/src/routes/api/auth/login.zig @@ -16,7 +16,7 @@ const Result = struct { pub const access = .everyone; pub fn post(ctx: *Context, body: Body) anyerror!Result { - var user = Storage.User.open(ctx.storage, body.user, ctx.allocator) catch return .{ + var user = Storage.User.open(ctx.storage, body.user) catch return .{ .success = false }; defer user.deinit(); diff --git a/src/routes/api/profile/root.zig b/src/routes/api/profile/root.zig index 632c09c..04bf042 100644 --- a/src/routes/api/profile/root.zig +++ b/src/routes/api/profile/root.zig @@ -1 +1,5 @@ +const memora = @import("memora"); +const HandlerInfo = memora.routes.HandlerInfo; + pub const image = @import("image/root.zig"); +pub const set: HandlerInfo = .from_type(@import("set.zig")); diff --git a/src/routes/api/profile/set.zig b/src/routes/api/profile/set.zig new file mode 100644 index 0000000..a007b23 --- /dev/null +++ b/src/routes/api/profile/set.zig @@ -0,0 +1,26 @@ +const std = @import("std"); + +const memora = @import("memora"); +const Context = memora.Context; +const Storage = memora.Storage; + +pub const access = .users; + +const Body = struct { + full_name: []const u8, + birthday: []const u8, +}; + +pub fn post(ctx: *Context, body: Body) !void { + if (ctx.storage.sessions.get(ctx.storage, ctx.fingerprint)) |session| { + var user = try Storage.User.open(ctx.storage, session.info.name); + defer user.deinit(); + + user.info.full_name = body.full_name; + user.info.birthday = body.birthday; + + try user.sync(); + } else { + return error.UnknownSession; + } +} diff --git a/src/routes/handler-info.zig b/src/routes/handler-info.zig index 97eb9bd..5183628 100644 --- a/src/routes/handler-info.zig +++ b/src/routes/handler-info.zig @@ -51,6 +51,9 @@ pub fn handle( storage: *Storage, allocator: std.mem.Allocator, ) !void { + const target = try allocator.dupe(u8, request.head.target); + defer allocator.free(target); + const handler = self.handler_from_method(request.head.method) orelse return request.respond( "{ \"error\": \"Bad Request\" }", .{ .status = .bad_request } @@ -102,7 +105,7 @@ pub fn handle( error.Forbidden => .{ "{ \"error\": \"Forbidden\" }", .forbidden }, error.NotFound => .{ "{ \"error\": \"Not Found\" }", .not_found }, else => blk: { - log.err("handler for '{s}' returned {}", .{request.head.target, err}); + log.err("handler for '{s}' returned {}", .{target, err}); break :blk .{ "{ \"error\": \"Internal Server Error\" }", .internal_server_error }; }, }; @@ -122,7 +125,7 @@ pub fn handle( if (context.response.headers.fingerprint) |auth_token| { var value = std.Io.Writer.Allocating.init(arena.allocator()); - try value.writer.print("fingerprint={s}; Secure; Path=/", .{auth_token}); + try value.writer.print("fingerprint={s}; Path=/", .{auth_token}); try headers.append(allocator, .{ .name = "Set-Cookie", @@ -184,7 +187,7 @@ fn HandlerWrapper(T: type, name: []const u8) type { return @call(.auto, @field(T, name), args); } else if (payload_type == void) { try @call(.auto, @field(T, name), args); - return memora.Stream.from_buffer(""); + return memora.Stream.from_buffer("{}"); } else { var writer = std.Io.Writer.Allocating.init(ctx.allocator); var stringify = std.json.Stringify { .writer = &writer.writer }; diff --git a/src/storage/user.zig b/src/storage/user.zig index 09f7716..86d82ff 100644 --- a/src/storage/user.zig +++ b/src/storage/user.zig @@ -48,9 +48,8 @@ arena: std.heap.ArenaAllocator, pub fn open( storage: *Storage, name: []const u8, - inner_allocator: std.mem.Allocator ) !Self { - var arena: std.heap.ArenaAllocator = .init(inner_allocator); + var arena: std.heap.ArenaAllocator = .init(storage.allocator); errdefer arena.deinit(); const allocator = arena.allocator(); @@ -125,9 +124,26 @@ pub fn new( }; } +pub fn set_password(self: *Self, password: []const u8) !void { + const allocator = self.area.allocator(); + const hash_buf = try allocator.alloc(u8, 256); + const hash = try std.crypto.pwhash.bcrypt.strHash( + password, + .{ + .params = .{ + .rounds_log = 3, + .silently_truncate_password = false, + }, + .encoding = .phc, + }, + hash_buf + ); + + self.info.hash = hash; +} + pub fn sync(self: *Self) !void { - const file = try self.dir.openFile("info.json", .{ - .mode = .write_only, + const file = try self.dir.createFile("info.json", .{ .lock = .exclusive, }); defer file.close(); diff --git a/static/api/profile.js b/static/api/profile.js index a9ee591..ec633e0 100644 --- a/static/api/profile.js +++ b/static/api/profile.js @@ -1 +1,8 @@ import * as rest from './rest.js'; + +export async function set(name, birthday) { + await rest.post('/api/profile/set', { + full_name: name, + birthday: birthday, + }); +} diff --git a/static/api/rest.js b/static/api/rest.js index b314615..6b14034 100644 --- a/static/api/rest.js +++ b/static/api/rest.js @@ -14,7 +14,7 @@ export function get(url) { return handle_fetch(fetch(url)); } -export async function post(url, body) { +export function post(url, body) { return handle_fetch(fetch(url, { method: 'POST', body: JSON.stringify(body), diff --git a/static/index.js b/static/index.js index c84aab9..d5d5c60 100644 --- a/static/index.js +++ b/static/index.js @@ -21,7 +21,7 @@ const reload = () => { image_viewer.clear(); api.images.list().then(images => { for (const image of images) { - image_viewer.add(`/api/image/load/${image.id}`); + image_viewer.add(image.id); } }); } diff --git a/static/pages/image-viewer/index.css b/static/pages/image-viewer/index.css index a1a9b76..4b1eb9b 100644 --- a/static/pages/image-viewer/index.css +++ b/static/pages/image-viewer/index.css @@ -8,7 +8,7 @@ padding: 10px; } -#container img { +#container sfw-image { margin: auto; max-width: 700px; width: 100%; diff --git a/static/pages/image-viewer/index.js b/static/pages/image-viewer/index.js index d28b3e1..0cf8598 100644 --- a/static/pages/image-viewer/index.js +++ b/static/pages/image-viewer/index.js @@ -1,6 +1,8 @@ import * as sfw from 'sfw'; const { Div, Img } = sfw.element.native; +import Image from '../../widgets/image/index.js'; + const css = await sfw.css(import.meta.url, './index.css') export default class ImageViewer extends sfw.element.Container { @@ -9,19 +11,16 @@ export default class ImageViewer extends sfw.element.Container { constructor() { super({ css }); + this.onnewer = () => {} + this.onolder = () => {} + this.body.append( this.#container = Div.new({ id: 'container' }) ); } - add(url) { - let image; - this.#container.append( - image = Img.new({ - className: 'hidden', - src: url, - onload: () => image.classList.remove('hidden') - })); + add(id) { + this.#container.append(Image.new({ id })) } clear() { diff --git a/static/pages/settings/index.js b/static/pages/settings/index.js index f316e8e..cc985a5 100644 --- a/static/pages/settings/index.js +++ b/static/pages/settings/index.js @@ -54,12 +54,14 @@ export default class SettingsView extends sfw.element.Container { }), this.#name = Editable.new({ title: 'Name', - value: '' + value: '', + onupdate: () => this.#update(), }), this.#birthday = Editable.new({ title: 'Birthday', type: 'date', value: '', + onupdate: () => this.#update(), }), Div.new({ id: 'logout', @@ -78,6 +80,10 @@ export default class SettingsView extends sfw.element.Container { ) } + #update() { + api.profile.set(this.#name.value, this.#birthday.value); + } + set profile(profile) { this.#profile = profile; diff --git a/static/widgets/editable/index.js b/static/widgets/editable/index.js index 8a72aff..0c93c4e 100644 --- a/static/widgets/editable/index.js +++ b/static/widgets/editable/index.js @@ -13,6 +13,8 @@ export default class Editable extends sfw.element.Container { constructor() { super({ css }); + this.onupdate = () => {}; + this.body.append( Div.new({ id: 'container', @@ -24,6 +26,7 @@ export default class Editable extends sfw.element.Container { if (e.key === 'Enter') { this.#input.readOnly = true; this.#update() + this.onupdate(); } }, }), @@ -33,6 +36,10 @@ export default class Editable extends sfw.element.Container { onclick: () => { this.#input.readOnly = !this.#input.readOnly; this.#update(); + + if (this.#input.readOnly) { + this.onupdate(); + } } }), ], @@ -59,6 +66,10 @@ export default class Editable extends sfw.element.Container { this.#input.value = value; } + get value() { + return this.#input.value; + } + set type(type) { this.#input.type = type; } diff --git a/static/widgets/image/index.css b/static/widgets/image/index.css new file mode 100644 index 0000000..f4e2dce --- /dev/null +++ b/static/widgets/image/index.css @@ -0,0 +1,59 @@ +#container { + position: relative; + background: var(--card-background); + height: 100%; +} + +#container img { + max-width: 700px; + width: 100%; + border-radius: var(--border-radius); + box-shadow: #223223aa 1px 1px 4px; + visibility: hidden; +} + +#container.loaded img { + visibility: visible; +} + +@keyframes loader { +} + +@keyframes loader { + 0% { + width: 60px; + height: 60px; + } + + 25% { + border: 5px solid var(--page-background); + } + + 50% { + width: 80px; + height: 80px; + } + + 75% { + border: 10px solid var(--page-background); + } + + 100% { + width: 60px; + height: 60px; + } +} + +#loading { + position: absolute; + border: 10px solid var(--page-background); + border-radius: 100%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation: 0.5s infinite loader ease; +} + +.loaded #loading { + display: none; +} diff --git a/static/widgets/image/index.js b/static/widgets/image/index.js new file mode 100644 index 0000000..ad77c4e --- /dev/null +++ b/static/widgets/image/index.js @@ -0,0 +1,30 @@ +import * as sfw from 'sfw'; +const { Div, Img } = sfw.element.native; + +const css = await sfw.css(import.meta.url, './index.css') + +export default class Image extends sfw.element.Container { + #container + #image + + constructor() { + super ({ css }) + + this.body.append( + this.#container = Div.new({ + id: 'container', + children: [ + this.#image = Img.new({ + onload: () => this.#container.classList.add('loaded'), + }), + Div.new({ id: 'loading' }) + ], + }) + ); + } + + set id(id) { + this.#container.classList.remove('loaded'); + this.#image.src = `/api/image/load/${id}`; + } +} -- cgit v1.2.3-70-g09d2