aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNathan Reiner <nathan@nathanreiner.xyz>2025-11-19 09:15:49 +0100
committerNathan Reiner <nathan@nathanreiner.xyz>2025-11-19 09:15:49 +0100
commit4c06eb64cbed3562e428ce59857d1763098638f3 (patch)
treecc9c8164e76cd48e1dd4ef963329dcfa3c1b152f
parent6201307fecf8398a1b53bf276bc08bfbb3524899 (diff)
allow images to upload and sort if according to their datetime
-rw-r--r--build.zig3
-rw-r--r--src/locked.zig13
-rw-r--r--src/root.zig1
-rw-r--r--src/routes/api/image/list.zig19
-rw-r--r--src/routes/api/image/load.zig2
-rw-r--r--src/routes/api/image/upload.zig2
-rw-r--r--src/routes/api/profile/image/load.zig17
-rw-r--r--src/routes/api/profile/image/root.zig5
-rw-r--r--src/routes/api/profile/image/upload.zig19
-rw-r--r--src/routes/api/profile/root.zig1
-rw-r--r--src/routes/api/root.zig1
-rw-r--r--src/routes/api/session/current.zig2
-rw-r--r--src/storage/image-manager/exif.zig54
-rw-r--r--src/storage/image-manager/image.zig38
-rw-r--r--src/storage/image-manager/root.zig79
-rw-r--r--src/storage/user.zig40
-rw-r--r--static/api/images.js16
-rw-r--r--static/api/index.js1
-rw-r--r--static/api/profile.js1
-rw-r--r--static/icon.pngbin0 -> 15387 bytes
-rw-r--r--static/icon.svg72
-rw-r--r--static/index.html1
-rw-r--r--static/index.js6
-rw-r--r--static/month.js4
-rw-r--r--static/pages/image-viewer/index.css4
-rw-r--r--static/pages/image-viewer/index.js8
-rw-r--r--static/pages/settings/index.css6
-rw-r--r--static/pages/settings/index.js46
28 files changed, 418 insertions, 43 deletions
diff --git a/build.zig b/build.zig
index c31450c..0f3fbf1 100644
--- a/build.zig
+++ b/build.zig
@@ -16,6 +16,7 @@ pub fn build(b: *std.Build) void {
mod.addOptions("config", opts);
mod.addImport("memora", mod);
+ mod.linkSystemLibrary("exif", .{});
const exe = b.addExecutable(.{
.name = "memora",
@@ -29,6 +30,8 @@ pub fn build(b: *std.Build) void {
}),
});
+ exe.linkLibC();
+
b.installArtifact(exe);
const run_step = b.step("run", "Run the app");
diff --git a/src/locked.zig b/src/locked.zig
new file mode 100644
index 0000000..6c2edfe
--- /dev/null
+++ b/src/locked.zig
@@ -0,0 +1,13 @@
+const std = @import("std");
+
+pub fn Shared(T: type) type {
+ return struct {
+ value: T,
+ rw_lock: *std.Thread.RwLock,
+
+ pub fn unlock(self: *@This()) void {
+ self.rw_lock.unlockShared();
+ self.* = undefined;
+ }
+ };
+}
diff --git a/src/root.zig b/src/root.zig
index 31cd751..ab7a62b 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -8,3 +8,4 @@ pub const Server = @import("server.zig");
pub const Storage = @import("storage/root.zig");
pub const Context = @import("context.zig");
pub const Stream = @import("stream/root.zig").Stream;
+pub const locked = @import("locked.zig");
diff --git a/src/routes/api/image/list.zig b/src/routes/api/image/list.zig
index d305276..5d2eace 100644
--- a/src/routes/api/image/list.zig
+++ b/src/routes/api/image/list.zig
@@ -7,6 +7,7 @@ pub const access = .users;
const ImageInfo = struct {
id: []const u8,
+ timestamp: ?i64,
};
const Result = struct {
@@ -16,13 +17,23 @@ const Result = struct {
pub fn get(ctx: *Context) !Result {
var images: std.ArrayList(ImageInfo) = .empty;
- const images_list = ctx.storage.images.list();
- defer images_list.deinit();
+ var images_list = ctx.storage.images.list();
+ defer images_list.unlock();
+
+ var locked = ctx.storage.images.first_by_timestamp();
+ defer locked.unlock();
+
+ var current = locked.value;
+
+ while (current) |c| {
+ const image = ctx.storage.images.items.items[c.index];
- for (images_list.images) |*image| {
try images.append(ctx.allocator, .{
- .id = image.id
+ .id = try ctx.allocator.dupe(u8, image.id),
+ .timestamp = image.timestamp,
});
+
+ current = c.next();
}
return .{
diff --git a/src/routes/api/image/load.zig b/src/routes/api/image/load.zig
index 4f0a072..1b2a3e2 100644
--- a/src/routes/api/image/load.zig
+++ b/src/routes/api/image/load.zig
@@ -8,6 +8,6 @@ pub const access = .users;
pub fn get(ctx: *Context) !memora.Stream {
const id = ctx.request.head.target["/api/image/load/".len..];
- var image = Storage.Image { .id = id };
+ var image = Storage.Image { .id = id, .timestamp = null };
return .from_file(try image.file(ctx.storage));
}
diff --git a/src/routes/api/image/upload.zig b/src/routes/api/image/upload.zig
index bbdd47c..72c8a2d 100644
--- a/src/routes/api/image/upload.zig
+++ b/src/routes/api/image/upload.zig
@@ -12,6 +12,6 @@ pub fn post(ctx: *Context) !void {
if (ctx.request.head.content_length) |length| {
var buffer: [1024]u8 = undefined;
const reader = try ctx.request.readerExpectContinue(&buffer);
- try ctx.storage.images.add(ctx.storage, reader, length);
+ try ctx.storage.images.save(ctx.storage, reader, length);
}
}
diff --git a/src/routes/api/profile/image/load.zig b/src/routes/api/profile/image/load.zig
new file mode 100644
index 0000000..4b696ce
--- /dev/null
+++ b/src/routes/api/profile/image/load.zig
@@ -0,0 +1,17 @@
+const std = @import("std");
+
+const memora = @import("memora");
+const Context = memora.Context;
+const Storage = memora.Storage;
+
+pub const access = .users;
+
+pub fn get(ctx: *Context) !memora.Stream {
+ const name = ctx.request.head.target["/api/profile/image/load/".len..];
+ if (Storage.User.image(ctx.storage, name)) |file| {
+ ctx.response.headers.content_type = "image/jpeg";
+ return .from_file(file);
+ }
+
+ return error.NotFound;
+}
diff --git a/src/routes/api/profile/image/root.zig b/src/routes/api/profile/image/root.zig
new file mode 100644
index 0000000..d6b77f2
--- /dev/null
+++ b/src/routes/api/profile/image/root.zig
@@ -0,0 +1,5 @@
+const memora = @import("memora");
+const HandlerInfo = memora.routes.HandlerInfo;
+
+pub const upload: HandlerInfo = .from_type(@import("upload.zig"));
+pub const load: HandlerInfo = .from_type(@import("load.zig"));
diff --git a/src/routes/api/profile/image/upload.zig b/src/routes/api/profile/image/upload.zig
new file mode 100644
index 0000000..db140d4
--- /dev/null
+++ b/src/routes/api/profile/image/upload.zig
@@ -0,0 +1,19 @@
+const std = @import("std");
+
+const memora = @import("memora");
+const Context = memora.Context;
+const Storage = memora.Storage;
+
+pub const access = .users;
+
+pub fn post(ctx: *Context) !void {
+ const name = ctx.request.head.target["/api/profile/image/upload/".len..];
+ if (ctx.request.head.content_length) |length| {
+ var buffer: [1024]u8 = undefined;
+ const reader = try ctx.request.readerExpectContinue(&buffer);
+
+ try Storage.User.set_image(ctx.storage, name, reader, length);
+
+ ctx.response.headers.content_type = "image/jpeg";
+ }
+}
diff --git a/src/routes/api/profile/root.zig b/src/routes/api/profile/root.zig
new file mode 100644
index 0000000..632c09c
--- /dev/null
+++ b/src/routes/api/profile/root.zig
@@ -0,0 +1 @@
+pub const image = @import("image/root.zig");
diff --git a/src/routes/api/root.zig b/src/routes/api/root.zig
index 6d5745f..5f731c3 100644
--- a/src/routes/api/root.zig
+++ b/src/routes/api/root.zig
@@ -1,3 +1,4 @@
pub const auth = @import("auth/root.zig");
pub const session = @import("session/root.zig");
pub const image = @import("image/root.zig");
+pub const profile = @import("profile/root.zig");
diff --git a/src/routes/api/session/current.zig b/src/routes/api/session/current.zig
index e86c115..897d01c 100644
--- a/src/routes/api/session/current.zig
+++ b/src/routes/api/session/current.zig
@@ -9,6 +9,7 @@ const Result = struct {
name: []const u8,
full_name: []const u8,
birthday: []const u8,
+ is_admin: bool,
};
pub fn get(ctx: *Context) !Result {
@@ -21,5 +22,6 @@ pub fn get(ctx: *Context) !Result {
.name = session.info.name,
.full_name = session.info.full_name,
.birthday = session.info.birthday,
+ .is_admin = session.info.is_admin,
};
}
diff --git a/src/storage/image-manager/exif.zig b/src/storage/image-manager/exif.zig
new file mode 100644
index 0000000..02199cf
--- /dev/null
+++ b/src/storage/image-manager/exif.zig
@@ -0,0 +1,54 @@
+const std = @import("std");
+const exif = @cImport(@cInclude("libexif/exif-data.h"));
+const cstd = @cImport(@cInclude("time.h"));
+
+const tags = [_]c_uint {
+ exif.EXIF_TAG_DATE_TIME,
+ exif.EXIF_TAG_DATE_TIME_ORIGINAL,
+ exif.EXIF_TAG_DATE_TIME_DIGITIZED,
+};
+
+pub fn get_date_time(path: [*:0]const u8) ?i64 {
+ const exif_data = exif.exif_data_new_from_file(path);
+ defer exif.exif_data_unref(exif_data);
+
+ if (exif_data == null) {
+ return null;
+ }
+
+ var entry: ?[*c]exif.struct__ExifEntry = null;
+ for (0..exif.EXIF_IFD_COUNT) |index| {
+ for (tags) |tag| {
+ entry = exif.exif_content_get_entry((exif_data.*).ifd[index], tag);
+
+ if (entry) |_| { break; }
+ }
+
+ if (entry) |_| { break; }
+ }
+
+ if (entry == null) {
+ return null;
+ }
+
+ const c_data = (entry.?.*).data;
+
+ return parse_date(c_data);
+}
+
+extern fn strptime(
+ s: [*c]const u8,
+ format: [*c]const u8,
+ tm: *cstd.tm,
+) ?*const u8;
+
+extern fn timegm(tm: *cstd.tm) i64;
+
+fn parse_date(date: [*:0]const u8) ?i64 {
+ var tm: cstd.tm = std.mem.zeroes(cstd.tm);
+ if (strptime(date, "%Y:%m:%d %H:%M:%S", &tm)) |_| {
+ return timegm(&tm);
+ }
+
+ return null;
+}
diff --git a/src/storage/image-manager/image.zig b/src/storage/image-manager/image.zig
index 91a094e..b11b7b1 100644
--- a/src/storage/image-manager/image.zig
+++ b/src/storage/image-manager/image.zig
@@ -1,6 +1,8 @@
const std = @import("std");
const Storage = @import("../root.zig");
+const exif = @import("exif.zig");
+
const id_size = 32;
const log = std.log.scoped(.image);
@@ -19,6 +21,7 @@ fn new_id() [id_size]u8 {
const Self = @This();
id: []const u8,
+timestamp: ?i64,
pub fn new(
storage: *Storage,
@@ -28,8 +31,11 @@ pub fn new(
var dir = try storage.dir.openDir("image", .{});
defer dir.close();
- var file_name: [id_size+4]u8 = undefined;
- const self: Self = .{ .id = &new_id() };
+ var self: Self = undefined;
+
+ self.id = try storage.allocator.dupe(u8, &new_id());
+
+ var file_name: [id_size + 4]u8 = undefined;
@memcpy(file_name[0..id_size], self.id);
@memcpy(file_name[id_size..], ".jpg");
@@ -43,14 +49,17 @@ pub fn new(
try file_writer.interface.flush();
- log.info("uploaded {s} [{} bytes]", .{self.id, size});
+ log.info("uploaded {s} [{} bytes]", .{ self.id, size });
+
+ self.timestamp = try load_timestamp(storage, self.id);
return self;
}
-pub fn init(allocator: std.mem.Allocator, id: []const u8) !Self {
+pub fn init(storage: *Storage, id: []const u8) !Self {
return .{
- .id = try allocator.dupe(u8, id),
+ .id = try storage.allocator.dupe(u8, id),
+ .timestamp = try load_timestamp(storage, id),
};
}
@@ -61,7 +70,7 @@ pub fn file(self: *Self, storage: *Storage) !std.fs.File {
};
defer dir.close();
- var file_name: [id_size+4]u8 = undefined;
+ var file_name: [id_size + 4]u8 = undefined;
@memcpy(file_name[0..id_size], self.id);
@memcpy(file_name[id_size..], ".jpg");
@@ -71,3 +80,20 @@ pub fn file(self: *Self, storage: *Storage) !std.fs.File {
pub fn deinit(self: *Self, allocator: std.mem.Allocator) void {
allocator.free(self.id);
}
+
+fn load_timestamp(storage: *Storage, id: []const u8) !?i64 {
+ var dir = storage.dir.openDir("image", .{}) catch blk: {
+ try storage.dir.makeDir("image");
+ break :blk try storage.dir.openDir("image", .{});
+ };
+ defer dir.close();
+
+ var file_name: [id_size + 4]u8 = undefined;
+ @memcpy(file_name[0..id_size], id);
+ @memcpy(file_name[id_size..], ".jpg");
+
+ var path_buffer: [std.fs.max_path_bytes:0]u8 = std.mem.zeroes([std.fs.max_path_bytes:0]u8);
+ const path = try dir.realpath(&file_name, &path_buffer);
+
+ return exif.get_date_time(@ptrCast(path));
+}
diff --git a/src/storage/image-manager/root.zig b/src/storage/image-manager/root.zig
index 7742df2..7fa1607 100644
--- a/src/storage/image-manager/root.zig
+++ b/src/storage/image-manager/root.zig
@@ -1,24 +1,30 @@
const std = @import("std");
-const Storage = @import("../root.zig");
+const memora = @import("memora");
+const Storage = memora.Storage;
pub const Image = @import("image.zig");
const Self = @This();
pub const empty: Self = .{};
-items: std.ArrayList(Image) = .empty,
-rw_lock: std.Thread.RwLock = .{},
+const Timestamp = struct {
+ index: usize,
+ node: std.DoublyLinkedList.Node,
-pub const LockedImages = struct {
- images: []Image,
- rw_lock: *std.Thread.RwLock,
+ pub fn next(self: *Timestamp) ?*Timestamp {
+ return @fieldParentPtr("node", self.node.next orelse return null);
+ }
- pub fn deinit(self: *const @This()) void {
- self.rw_lock.unlockShared();
+ pub fn prev(self: *Timestamp) ?*Timestamp {
+ return @fieldParentPtr("node", self.node.prev orelse return null);
}
};
+items: std.ArrayList(Image) = .empty,
+rw_lock: std.Thread.RwLock = .{},
+timestamp_order: std.DoublyLinkedList = .{},
+
pub fn init(
self: *Self,
storage: *Storage,
@@ -35,32 +41,67 @@ pub fn init(
while (try iterator.next()) |entry| {
if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".jpg")) {
- try self.items.append(
+ try self.add(
storage.allocator,
- try .init(storage.allocator, entry.name[0..entry.name.len - 4])
+ try .init(storage, entry.name[0..entry.name.len - 4])
);
}
}
}
-pub fn add(
+pub fn add(self: *Self, allocator: std.mem.Allocator, image: Image) !void {
+ self.rw_lock.lock();
+ defer self.rw_lock.unlock();
+
+ try self.items.append(
+ allocator,
+ image,
+ );
+
+ var current = self.timestamp_order.first;
+
+ if (current == null) {
+ const timestamp = try allocator.create(Timestamp);
+ timestamp.index = self.items.items.len - 1;
+ self.timestamp_order.append(&timestamp.node);
+ return;
+ }
+
+ while (current) |c| {
+ const index = @as(*Timestamp, @fieldParentPtr("node", c)).index;
+ if (image.timestamp orelse 0 > self.items.items[index].timestamp orelse 0) {
+ const timestamp = try allocator.create(Timestamp);
+ timestamp.index = self.items.items.len - 1;
+ self.timestamp_order.insertBefore(c, &timestamp.node);
+ break;
+ }
+
+ current = c.next;
+ }
+}
+
+pub fn save(
self: *Self,
storage: *Storage,
reader: *std.Io.Reader,
size: usize,
) !void {
- self.rw_lock.lock();
- defer self.rw_lock.unlock();
+ return self.add(storage.allocator, try Image.new(storage, reader, size));
+}
- try self.items.append(
- storage.allocator,
- try Image.new(storage, reader, size),
- );
+pub fn list(self: *Self) memora.locked.Shared([]Image) {
+ self.rw_lock.lockShared();
+ return .{
+ .value = self.items.items,
+ .rw_lock = &self.rw_lock,
+ };
}
-pub fn list(self: *Self) LockedImages {
+pub fn first_by_timestamp(self: *Self) memora.locked.Shared(?*Timestamp) {
+ self.rw_lock.lockShared();
return .{
- .images = self.items.items,
+ .value = if (self.timestamp_order.first) |first| @fieldParentPtr("node", first)
+ else null,
.rw_lock = &self.rw_lock,
};
}
diff --git a/src/storage/user.zig b/src/storage/user.zig
index d5260fe..09f7716 100644
--- a/src/storage/user.zig
+++ b/src/storage/user.zig
@@ -1,6 +1,8 @@
const std = @import("std");
const Storage = @import("root.zig");
+const log = std.log.scoped(.user);
+
const Self = @This();
pub const Info = struct {
@@ -157,8 +159,42 @@ pub fn deinit(self: *Self) void {
pub fn exists(storage: *Storage, name: []const u8) bool {
var user = storage.dir.openDir("user", .{}) catch return false;
- defer user.deinit();
+ defer user.close();
- user.access(name) catch return false;
+ user.access(name, .{}) catch return false;
return true;
}
+
+pub fn image(storage: *Storage, name: []const u8) ?std.fs.File {
+ var user = storage.dir.openDir("user", .{}) catch return null;
+ defer user.close();
+
+ var profile = user.openDir(name, .{}) catch return null;
+ defer profile.close();
+ return profile.openFile("image.jpg", .{ .lock = .shared }) catch null;
+}
+
+pub fn set_image(
+ storage: *Storage,
+ name: []const u8,
+ reader: *std.Io.Reader,
+ size: usize,
+) !void {
+ var user = try storage.dir.openDir("user", .{});
+ defer user.close();
+
+ var profile = try user.openDir(name, .{});
+ defer profile.close();
+
+ var image_file = try profile.createFile("image.jpg", .{ .lock = .exclusive });
+ defer image_file.close();
+
+ var buffer: [1024]u8 = undefined;
+ var file_writer = image_file.writer(&buffer);
+
+ log.info("uploaded to profile {s} [{} bytes]", .{ name, size });
+
+ try reader.streamExact(&file_writer.interface, size);
+
+ try file_writer.interface.flush();
+}
diff --git a/static/api/images.js b/static/api/images.js
index 4bdac1f..aa783e9 100644
--- a/static/api/images.js
+++ b/static/api/images.js
@@ -52,15 +52,27 @@ export async function upload_to_timeline() {
})
}
-export async function upload_to_profile() {
+export async function upload_to_profile(id) {
const input = Input.new({
type: 'file',
multiple: false,
accept: 'image/jpeg',
})
input.click();
+
+ return new Promise((resolve) => {
+ input.onchange = () => {
+ resolve(new FileUploader(`/api/profile/image/upload/${id}`, [...input.files]));
+ }
+ })
}
export function list() {
- return rest.get('/api/image/list').then(r => r.images);
+ return rest.get('/api/image/list')
+ .then(r => {
+ r.images.forEach(i => {
+ i.date = new Date(i.date * 1000)
+ });
+ return r.images;
+ });
}
diff --git a/static/api/index.js b/static/api/index.js
index 3f2f483..cddd7bd 100644
--- a/static/api/index.js
+++ b/static/api/index.js
@@ -1,3 +1,4 @@
export * as images from './images.js';
export * as auth from './auth.js';
export * as session from './session.js';
+export * as profile from './profile.js';
diff --git a/static/api/profile.js b/static/api/profile.js
new file mode 100644
index 0000000..a9ee591
--- /dev/null
+++ b/static/api/profile.js
@@ -0,0 +1 @@
+import * as rest from './rest.js';
diff --git a/static/icon.png b/static/icon.png
new file mode 100644
index 0000000..fc34cde
--- /dev/null
+++ b/static/icon.png
Binary files differ
diff --git a/static/icon.svg b/static/icon.svg
new file mode 100644
index 0000000..bce418c
--- /dev/null
+++ b/static/icon.svg
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="512"
+ height="512"
+ viewBox="0 0 512 512"
+ version="1.1"
+ id="svg1"
+ inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
+ sodipodi:docname="icon.svg"
+ inkscape:export-filename="icon.png"
+ inkscape:export-xdpi="96"
+ inkscape:export-ydpi="96"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:svg="http://www.w3.org/2000/svg">
+ <sodipodi:namedview
+ id="namedview1"
+ pagecolor="#ffffff"
+ bordercolor="#000000"
+ borderopacity="0.25"
+ inkscape:showpageshadow="2"
+ inkscape:pageopacity="0.0"
+ inkscape:pagecheckerboard="0"
+ inkscape:deskcolor="#d1d1d1"
+ inkscape:document-units="px"
+ inkscape:zoom="0.8457031"
+ inkscape:cx="256.59123"
+ inkscape:cy="141.89377"
+ inkscape:window-width="1914"
+ inkscape:window-height="1054"
+ inkscape:window-x="0"
+ inkscape:window-y="0"
+ inkscape:window-maximized="1"
+ inkscape:current-layer="layer1" />
+ <defs
+ id="defs1" />
+ <g
+ inkscape:label="Layer 1"
+ inkscape:groupmode="layer"
+ id="layer1">
+ <path
+ id="rect1-3"
+ style="fill:#999999;stroke-width:4.62341;stroke-linecap:round;stroke-linejoin:round"
+ d="M 68.51711,75.315691 257.62072,4.3656212 c 14.8546,-5.5733237 28.63849,12.7727618 28.63849,28.6384698 V 310.18366 c 0,15.86572 -13.78389,23.06516 -28.63849,28.63848 L 68.51711,409.77221 C 53.662516,415.34554 39.87863,396.99945 39.87863,381.13373 V 103.95416 c 0,-15.865708 13.783886,-23.065146 28.63848,-28.638469 z"
+ sodipodi:nodetypes="sssssssss" />
+ <path
+ id="rect1"
+ style="fill:#b3b3b3;stroke-width:4.62341;stroke-linecap:round;stroke-linejoin:round"
+ d="M 161.44819,124.24677 350.55181,53.296701 c 14.85459,-5.573324 28.63848,12.772764 28.63848,28.638472 V 359.11475 c 0,15.86572 -13.78389,23.06515 -28.63848,28.63848 L 161.44819,458.7033 c -14.85459,5.57332 -28.63848,-12.77277 -28.63848,-28.63849 V 152.88524 c 0,-15.86571 13.78389,-23.06514 28.63848,-28.63847 z"
+ sodipodi:nodetypes="sssssssss" />
+ <path
+ id="rect1-1"
+ style="fill:#cccccc;stroke-width:4.62341;stroke-linecap:round;stroke-linejoin:round"
+ d="m 254.37928,173.17786 189.10361,-70.95007 c 14.85459,-5.573328 28.63848,12.77276 28.63848,28.63847 v 277.17957 c 0,15.86572 -13.78389,23.06516 -28.63848,28.63848 l -189.10361,70.95007 c -14.8546,5.57332 -28.63849,-12.77276 -28.63849,-28.63848 V 201.81632 c 0,-15.8657 13.78389,-23.06514 28.63849,-28.63846 z"
+ sodipodi:nodetypes="sssssssss" />
+ <text
+ xml:space="preserve"
+ style="font-size:24px;font-family:'Sans Serif';-inkscape-font-specification:'Sans Serif';text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;white-space:pre;inline-size:34.4024;display:inline;fill:#4d4d4d;stroke-width:3.77953;stroke-linecap:round;stroke-linejoin:round"
+ x="254.17958"
+ y="345.31635"
+ id="text1"
+ transform="matrix(5.3706663,0,0,5.3706663,-1094.1078,-1507.2055)"><tspan
+ x="254.17958"
+ y="345.31635"
+ id="tspan3"><tspan
+ style="font-family:Pacifico;-inkscape-font-specification:Pacifico"
+ id="tspan2">M</tspan></tspan></text>
+ </g>
+</svg>
diff --git a/static/index.html b/static/index.html
index 7450237..c69eb16 100644
--- a/static/index.html
+++ b/static/index.html
@@ -14,6 +14,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&family=Pacifico&display=swap" rel="stylesheet">
+ <link rel="icon" href="icon.png" type="image/png">
</head>
<body>
</body>
diff --git a/static/index.js b/static/index.js
index 2fcca88..c84aab9 100644
--- a/static/index.js
+++ b/static/index.js
@@ -100,7 +100,11 @@ const main = MainView.new({
main.active_kind = MainView.Kind.home;
main.active_view = shuffle;
},
- onsettings: () => {
+ onsettings: async () => {
+ if (!settings.profile) {
+ settings.profile = await api.session.current();
+ }
+
main.active_kind = MainView.Kind.home;
main.active_view = settings;
},
diff --git a/static/month.js b/static/month.js
index 9ed0467..7875f36 100644
--- a/static/month.js
+++ b/static/month.js
@@ -80,6 +80,10 @@ export default class Month {
static literal(strings) {
return Month.from_string(strings[0]);
}
+
+ static get current() {
+ return Month.from_date(new Date());
+ }
}
export const literal = Month.literal;
diff --git a/static/pages/image-viewer/index.css b/static/pages/image-viewer/index.css
index 6f53333..a1a9b76 100644
--- a/static/pages/image-viewer/index.css
+++ b/static/pages/image-viewer/index.css
@@ -15,3 +15,7 @@
border-radius: var(--border-radius);
box-shadow: #223223aa 1px 1px 4px;
}
+
+#container img.hidden {
+ filter: brightness(0) invert();
+}
diff --git a/static/pages/image-viewer/index.js b/static/pages/image-viewer/index.js
index f65c12b..d28b3e1 100644
--- a/static/pages/image-viewer/index.js
+++ b/static/pages/image-viewer/index.js
@@ -15,7 +15,13 @@ export default class ImageViewer extends sfw.element.Container {
}
add(url) {
- this.#container.append(Img.new({ src: url }));
+ let image;
+ this.#container.append(
+ image = Img.new({
+ className: 'hidden',
+ src: url,
+ onload: () => image.classList.remove('hidden')
+ }));
}
clear() {
diff --git a/static/pages/settings/index.css b/static/pages/settings/index.css
index eb718ab..0d6b234 100644
--- a/static/pages/settings/index.css
+++ b/static/pages/settings/index.css
@@ -21,6 +21,7 @@
}
#image-container {
+ background: #fff;
position: relative;
border-radius: 100%;
width: 100%;
@@ -31,6 +32,7 @@
}
#image-container img {
+ visibility: hidden;
position: absolute;
top: 50%;
left: 50%;
@@ -43,6 +45,10 @@
user-select: none;
}
+#image-container img[src] {
+ visibility: visible;
+}
+
#profile-image #edit-container {
overflow: unset;
}
diff --git a/static/pages/settings/index.js b/static/pages/settings/index.js
index c11a3e9..f316e8e 100644
--- a/static/pages/settings/index.js
+++ b/static/pages/settings/index.js
@@ -1,6 +1,8 @@
import * as sfw from 'sfw';
const { Div, Img } = sfw.element.native;
+import * as api from '../../api/index.js';
+
import Editable from '../../widgets/editable/index.js';
import icons from '../../icons/index.js';
@@ -8,6 +10,12 @@ import icons from '../../icons/index.js';
const css = await sfw.css(import.meta.url, './index.css');
export default class SettingsView extends sfw.element.Container {
+ #profile_image
+ #name
+ #birthday
+
+ #profile
+
constructor() {
super({ css });
@@ -23,23 +31,35 @@ export default class SettingsView extends sfw.element.Container {
Div.new({
id: 'image-container',
children: [
- Img.new(),
+ this.#profile_image = Img.new(),
]
}),
Div.new({
id: 'edit',
- children: [ icons.edit ]
+ children: [ icons.edit ],
+ onclick: async () => {
+ const uploader = await api.images.upload_to_profile(this.#profile.name);
+ uploader.ondone = async () => {
+ const blob = await fetch(
+ `/api/profile/image/load/${this.#profile.name}`,
+ {cache: 'reload', mode: 'no-cors'}
+ ).then(r => r.blob());
+
+ this.#profile_image.src = URL.createObjectURL(blob);
+ };
+ uploader.send()
+ }
}),
]
}),
- Editable.new({
+ this.#name = Editable.new({
title: 'Name',
- value: 'Nathan Reiner'
+ value: ''
}),
- Editable.new({
+ this.#birthday = Editable.new({
title: 'Birthday',
type: 'date',
- value: '2002-08-06',
+ value: '',
}),
Div.new({
id: 'logout',
@@ -57,4 +77,18 @@ export default class SettingsView extends sfw.element.Container {
})
)
}
+
+ set profile(profile) {
+ this.#profile = profile;
+
+ this.#profile_image.src = `/api/profile/image/load/${profile.name}`;
+ this.#profile_image.onerror = () => this.#profile_image.removeAttribute('src');
+
+ this.#name.value = profile.full_name;
+ this.#birthday.value = profile.birthday;
+ }
+
+ get profile() {
+ return this.#profile;
+ }
}