aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNathan Reiner <nathan@nathanreiner.xyz>2025-11-14 21:55:59 +0100
committerNathan Reiner <nathan@nathanreiner.xyz>2025-11-14 21:55:59 +0100
commit3f18f02d07802d1fc705a500e5978a9b3cb2e751 (patch)
tree283970a2f5a693706456b853c550eeaa669b5d72
parent351ad457f0ff95e20301a146b8c88a8f0f659aa1 (diff)
implement login
-rw-r--r--src/config.zig1
-rw-r--r--src/main.zig14
-rw-r--r--src/mime.zig (renamed from src/http.zig)17
-rw-r--r--src/prompt.zig44
-rw-r--r--src/root.zig1
-rw-r--r--src/routes/api/auth/first-login.zig14
-rw-r--r--src/routes/api/auth/login.zig25
-rw-r--r--src/routes/api/auth/root.zig4
-rw-r--r--src/routes/api/root.zig1
-rw-r--r--src/routes/context.zig14
-rw-r--r--src/routes/fallback.zig35
-rw-r--r--src/routes/handler-info.zig113
-rw-r--r--src/routes/login.zig17
-rw-r--r--src/routes/root.zig55
-rw-r--r--src/storage/root.zig57
-rw-r--r--src/storage/user.zig133
-rw-r--r--static/api/auth.js12
-rw-r--r--static/api/images.js20
-rw-r--r--static/api/index.js13
-rw-r--r--static/api/rest.js22
-rw-r--r--static/icons/index.js1
-rw-r--r--static/icons/next.js3
-rw-r--r--static/index.js31
-rw-r--r--static/pages/login/index.css69
-rw-r--r--static/pages/login/index.js66
-rw-r--r--static/pages/settings/index.css2
-rw-r--r--static/pages/settings/index.js2
27 files changed, 626 insertions, 160 deletions
diff --git a/src/config.zig b/src/config.zig
new file mode 100644
index 0000000..8d4ab29
--- /dev/null
+++ b/src/config.zig
@@ -0,0 +1 @@
+pub const storage_path = "./storage/";
diff --git a/src/main.zig b/src/main.zig
index be43352..40f4016 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -8,6 +8,7 @@ pub const std_options = std.Options {
const log = std.log.scoped(.main);
var net_server: std.net.Server = undefined;
+var storage: memora.Storage = undefined;
fn signal_handler(signo: i32) callconv(.c) void {
if (signo == std.os.linux.SIG.INT) {
@@ -28,17 +29,18 @@ fn register_sigaction() void {
}
pub fn main() !void {
- register_sigaction();
-
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.debug.assert(gpa.deinit() == .ok);
-
const allocator = gpa.allocator();
+ storage = try .init(allocator);
+
const address = try std.net.Address.parseIpAndPort("0.0.0.0:8080");
net_server = try address.listen(.{ .reuse_address = true });
defer net_server.deinit();
+ register_sigaction();
+
log.info("listening on {f}", .{address});
while (true) {
@@ -58,6 +60,10 @@ pub fn main() !void {
log.info("{} {s}", .{request.head.method, request.head.target});
const handler_info = memora.routes.get(request.head.target);
- try handler_info.handle(&request, allocator);
+ try handler_info.handle(
+ &request,
+ &storage,
+ allocator
+ );
}
}
diff --git a/src/http.zig b/src/mime.zig
index ad4ecb6..0082f80 100644
--- a/src/http.zig
+++ b/src/mime.zig
@@ -1,22 +1,9 @@
const std = @import("std");
-pub fn respond_file(
- request: *std.http.Server.Request,
- file: std.fs.File,
- file_name: []const u8,
- allocator: std.mem.Allocator,
-) !void {
- const buffer = try file.readToEndAlloc(allocator, std.math.maxInt(usize));
- defer allocator.free(buffer);
-
+pub fn get_type(file_name: []const u8) []const u8 {
const suffix_pos = std.mem.lastIndexOfScalar(u8, file_name, '.') orelse file_name.len;
const suffix = file_name[suffix_pos..];
-
- try request.respond(buffer, .{
- .extra_headers = &.{
- .{ .name = "Content-Type", .value = content_types.get(suffix) orelse "text/plain" },
- }
- });
+ return content_types.get(suffix) orelse "text/plain";
}
pub const content_types = std.StaticStringMap([]const u8).initComptime(.{
diff --git a/src/prompt.zig b/src/prompt.zig
new file mode 100644
index 0000000..9fa9a9a
--- /dev/null
+++ b/src/prompt.zig
@@ -0,0 +1,44 @@
+const std = @import("std");
+const ansi = @import("ansi.zig");
+
+var output_buffer: [1024]u8 = undefined;
+var writer_stderr = std.fs.File.stderr().writer(&output_buffer);
+const stderr = &writer_stderr.interface;
+
+var input_buffer: [1024]u8 = undefined;
+var reader_stdin = std.fs.File.stdin().reader(&input_buffer);
+const stdin = &reader_stdin.interface;
+
+pub fn read(
+ prompt: []const u8,
+ allocator: std.mem.Allocator,
+) ![]u8 {
+ try stderr.print(
+ "{f}{f}{f} ",
+ .{
+ ansi.Styled {
+ .text = " ",
+ .bg = .blue,
+ .bold = true,
+ },
+ ansi.Styled {
+ .text = prompt,
+ .bg = .blue,
+ .bold = true,
+ },
+ ansi.Styled {
+ .text = ": ",
+ .bg = .blue,
+ .bold = true,
+ },
+ },
+ );
+
+ try stderr.flush();
+
+ var output = std.Io.Writer.Allocating.init(allocator);
+ errdefer output.deinit();
+ _ = try stdin.streamDelimiter(&output.writer, '\n');
+ stdin.toss(1);
+ return output.toOwnedSlice();
+}
diff --git a/src/root.zig b/src/root.zig
index 87f1892..301664e 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -2,4 +2,5 @@
const std = @import("std");
pub const log = @import("log.zig");
pub const routes = @import("routes/root.zig");
+pub const Storage = @import("storage/root.zig");
diff --git a/src/routes/api/auth/first-login.zig b/src/routes/api/auth/first-login.zig
new file mode 100644
index 0000000..2fb8c02
--- /dev/null
+++ b/src/routes/api/auth/first-login.zig
@@ -0,0 +1,14 @@
+const std = @import("std");
+const Context = @import("../../context.zig");
+
+pub const needs_auth = true;
+pub const method = .POST;
+
+const Result = struct {
+ is_first: bool,
+};
+
+pub fn handler(ctx: *Context) anyerror!Result {
+ _ = ctx;
+ return .{ .is_first = false };
+}
diff --git a/src/routes/api/auth/login.zig b/src/routes/api/auth/login.zig
new file mode 100644
index 0000000..c3f2bef
--- /dev/null
+++ b/src/routes/api/auth/login.zig
@@ -0,0 +1,25 @@
+const std = @import("std");
+
+const Context = @import("../../context.zig");
+const Storage = @import("../../../storage/root.zig");
+
+pub const needs_auth = false;
+pub const method = .POST;
+
+const Body = struct {
+ user: []const u8,
+ password: []const u8,
+};
+
+const Result = struct {
+ success: bool,
+};
+
+pub fn handler(ctx: *Context, body: Body) anyerror!Result {
+ var user = Storage.User.open(ctx.storage, body.user, ctx.allocator) catch return .{
+ .success = false
+ };
+ defer user.deinit();
+
+ return .{ .success = user.check_password(body.password) };
+}
diff --git a/src/routes/api/auth/root.zig b/src/routes/api/auth/root.zig
new file mode 100644
index 0000000..785271e
--- /dev/null
+++ b/src/routes/api/auth/root.zig
@@ -0,0 +1,4 @@
+const HandlerInfo = @import("../../handler-info.zig");
+
+pub const login: HandlerInfo = .from_type(@import("login.zig"));
+pub const first_login: HandlerInfo = .from_type(@import("first-login.zig"));
diff --git a/src/routes/api/root.zig b/src/routes/api/root.zig
new file mode 100644
index 0000000..19c4074
--- /dev/null
+++ b/src/routes/api/root.zig
@@ -0,0 +1 @@
+pub const auth = @import("auth/root.zig");
diff --git a/src/routes/context.zig b/src/routes/context.zig
new file mode 100644
index 0000000..bc3e710
--- /dev/null
+++ b/src/routes/context.zig
@@ -0,0 +1,14 @@
+const std = @import("std");
+const Storage = @import("../storage/root.zig");
+
+const Self = @This();
+
+allocator: std.mem.Allocator,
+request: *std.http.Server.Request,
+storage: *Storage,
+user: ?[]const u8 = null,
+response: struct {
+ headers: struct {
+ content_type: []const u8 = "application/json",
+ } = .{},
+} = .{},
diff --git a/src/routes/fallback.zig b/src/routes/fallback.zig
index 78d778b..25e1927 100644
--- a/src/routes/fallback.zig
+++ b/src/routes/fallback.zig
@@ -1,32 +1,37 @@
const std = @import("std");
-const http = @import("../http.zig");
+const mime = @import("../mime.zig");
+const Context = @import("context.zig");
const log = std.log.scoped(.fallback);
-pub fn handler(
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
-) anyerror!void {
+pub const needs_auth = false;
+pub const method = .GET;
+
+pub fn handler(ctx: *Context) anyerror![]const u8 {
var static = try std.fs.cwd().openDir("static", .{});
defer static.close();
- if (static.openFile(request.head.target[1..], .{})) |file| {
+ if (static.openFile(ctx.request.head.target[1..], .{})) |file| {
defer file.close();
- try http.respond_file(request, file, request.head.target[1..], allocator);
+ const content = file.readToEndAlloc(ctx.allocator, std.math.maxInt(usize));
+ const mime_type = mime.get_type(ctx.request.head.target);
+ ctx.response.headers.content_type = mime_type;
+ return content;
} else |_| {
- var subdir = if (request.head.target.len == 1) static
- else (static.openDir(request.head.target[1..], .{}) catch {
- try request.respond("Not Found", .{ .status = .not_found });
- return;
+ var subdir = if (ctx.request.head.target.len == 1) static
+ else (static.openDir(ctx.request.head.target[1..], .{}) catch {
+ return error.NotFound;
});
- defer if (request.head.target.len > 1) subdir.close();
+ defer if (ctx.request.head.target.len > 1) subdir.close();
if (subdir.openFile("index.html", .{})) |file| {
defer file.close();
- try http.respond_file(request, file, "index.html", allocator);
+ const content = file.readToEndAlloc(ctx.allocator, std.math.maxInt(usize));
+ ctx.response.headers.content_type = "text/html";
+ return content;
} else |_| {
- log.warn("File '{s}' Not Found", .{ request.head.target });
- try request.respond("Not Found", .{ .status = .not_found });
+ log.warn("File '{s}' Not Found", .{ ctx.request.head.target });
+ return error.NotFound;
}
}
}
diff --git a/src/routes/handler-info.zig b/src/routes/handler-info.zig
new file mode 100644
index 0000000..bac7612
--- /dev/null
+++ b/src/routes/handler-info.zig
@@ -0,0 +1,113 @@
+const std = @import("std");
+const Context = @import("context.zig");
+const Storage = @import("../storage/root.zig");
+
+const log = std.log.scoped(.handler_info);
+
+handler: *const fn (*Context) anyerror![]const u8,
+needs_auth: bool,
+method: std.http.Method,
+
+pub fn handle(
+ self: *const @This(),
+ request: *std.http.Server.Request,
+ storage: *Storage,
+ allocator: std.mem.Allocator,
+) !void {
+ if (request.head.method != self.method) {
+ return request.respond(
+ "{ \"error\": \"Bad Request\" }",
+ .{ .status = .bad_request }
+ );
+ }
+
+ var arena = std.heap.ArenaAllocator.init(allocator);
+ defer arena.deinit();
+ var context: Context = .{
+ .request = request,
+ .storage = storage,
+ .allocator = arena.allocator(),
+ };
+
+ const response = self.handler(&context) catch |err| {
+ const response, const status_code: std.http.Status = switch (err) {
+ error.BadRequest => .{ "{ \"error\": \"Bad Request\" }", .bad_request },
+ error.Unauthorized => .{ "{ \"error\": \"Unauthorized\" }", .unauthorized },
+ error.Forbidden => .{ "{ \"error\": \"Forbidden\" }", .forbidden },
+ error.NotFound => .{ "{ \"error\": \"Not Found\" }", .not_found },
+ else => blk: {
+ log.err("handler for '{s}' returned {}", .{request.head.target, err});
+ break :blk .{ "{ \"error\": \"Internal Server Error\" }", .internal_server_error };
+ },
+ };
+
+ return request.respond(response, .{ .status = status_code });
+ };
+
+ try request.respond(response, .{
+ .extra_headers = &.{
+ .{
+ .name = "Content-Type",
+ .value = context.response.headers.content_type
+ },
+ },
+ });
+}
+
+pub fn from_type(T: type) @This() {
+ const info = @typeInfo(@TypeOf(T.handler));
+ const return_type = info.@"fn".return_type orelse void;
+ const payload_type = @typeInfo(return_type).error_union.payload;
+
+ const Handler = type: {
+ break :type struct {
+ pub fn call(ctx: *Context) anyerror![]const u8 {
+ const args = args: {
+ const tuple = std.meta.fields(std.meta.ArgsTuple(@TypeOf(T.handler)));
+
+ if (tuple.len == 1) {
+ break :args .{ ctx };
+ } else if (tuple.len == 2) {
+ const Body = tuple[1].@"type";
+
+ var writer = std.Io.Writer.Allocating.init(ctx.allocator);
+ const interface = &writer.writer;
+
+ var buffer: [1024]u8 = undefined;
+ const reader = try ctx.request.readerExpectContinue(&buffer);
+
+ try reader.streamExact64(interface, ctx.request.head.content_length orelse 0);
+
+ const body = std.json.parseFromSliceLeaky(
+ Body,
+ ctx.allocator,
+ writer.written(),
+ .{}
+ ) catch return error.BadRequest;
+ break :args .{ ctx, body };
+ } else {
+ @compileError("invalid amount of arguments for request function");
+ }
+ };
+
+ if (payload_type == []const u8) {
+ return @call(.auto, T.handler, args);
+ } else if (payload_type == void) {
+ try @call(.auto, T.handler, args);
+ return "";
+ } else {
+ var writer = std.Io.Writer.Allocating.init(ctx.allocator);
+ var stringify = std.json.Stringify { .writer = &writer.writer };
+ try stringify.write(try @call(.auto, T.handler, args));
+ return writer.written();
+ }
+ }
+ };
+ };
+
+ return @This() {
+ .handler = Handler.call,
+ .needs_auth = T.needs_auth,
+ .method = T.method,
+ };
+}
diff --git a/src/routes/login.zig b/src/routes/login.zig
deleted file mode 100644
index 29bf370..0000000
--- a/src/routes/login.zig
+++ /dev/null
@@ -1,17 +0,0 @@
-const std = @import("std");
-
-pub fn handler(
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
-) anyerror!void {
- var output = std.Io.Writer.Allocating.init(allocator);
- var stringify = std.json.Stringify { .writer = &output.writer };
-
- try stringify.write(.{
- .user = "nathan.reiner",
- .name = "Nathan Reiner",
- });
-
- try output.writer.flush();
- try request.respond(output.written(), .{});
-}
diff --git a/src/routes/root.zig b/src/routes/root.zig
index 01952bb..dbfce32 100644
--- a/src/routes/root.zig
+++ b/src/routes/root.zig
@@ -1,56 +1,15 @@
const std = @import("std");
-pub const fallback = @import("fallback.zig");
-pub const login = @import("login.zig");
+pub const HandlerInfo = @import("handler-info.zig");
-pub const HandlerInfo = struct {
- handler: *const fn (
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
- ) anyerror!void,
- needs_auth: bool,
- method: std.http.Method,
-
- pub fn handle(
- self: *const @This(),
- request: *std.http.Server.Request,
- allocator: std.mem.Allocator,
- ) !void {
- if (request.head.method != self.method) {
- try request.respond("{ \"error\": \"Bad Request\" }", .{ .status = .bad_request });
- }
-
- self.handler(request, allocator) catch |err| {
- const response, const status_code: std.http.Status = switch (err) {
- error.BadRequest => .{ "{ \"error\": \"Bad Request\" }", .bad_request },
- error.Unauthorized => .{ "{ \"error\": \"Unauthorized\" }", .unauthorized },
- error.Forbidden => .{ "{ \"error\": \"Forbidden\" }", .forbidden },
- error.NotFound => .{ "{ \"error\": \"Not Found\" }", .not_found },
- else => .{ "{ \"error\": \"Internal Server Error\" }", .internal_server_error },
- };
-
- try request.respond(response, .{ .status = status_code });
- };
- }
-};
+pub const Context = @import("context.zig");
+pub const api = @import("api/root.zig");
+pub const fallback: HandlerInfo = .from_type(@import("fallback.zig"));
pub const handlers = std.StaticStringMap(HandlerInfo).initComptime(.{
- .{
- "",
- HandlerInfo {
- .handler = fallback.handler,
- .needs_auth = false,
- .method = .GET,
- }
- },
- .{
- "/api/login",
- HandlerInfo {
- .handler = login.handler,
- .needs_auth = false,
- .method = .POST,
- }
- },
+ .{ "", fallback },
+ .{ "/api/auth/login", api.auth.login },
+ .{ "/api/auth/first-login", api.auth.first_login },
});
pub fn get(path: []const u8) HandlerInfo {
diff --git a/src/storage/root.zig b/src/storage/root.zig
new file mode 100644
index 0000000..bf9803e
--- /dev/null
+++ b/src/storage/root.zig
@@ -0,0 +1,57 @@
+const std = @import("std");
+const config = @import("../config.zig");
+const prompt = @import("../prompt.zig");
+
+pub const User = @import("user.zig");
+
+const Self = @This();
+
+dir: std.fs.Dir,
+
+pub fn init(allocator: std.mem.Allocator) !Self {
+ const dir = std.fs.cwd().openDir(config.storage_path, .{}) catch blk: {
+ try std.fs.cwd().makeDir(config.storage_path);
+ break :blk try std.fs.cwd().openDir(config.storage_path, .{});
+ };
+
+ var self = Self { .dir = dir };
+
+ dir.access("user", .{}) catch |err| switch (err) {
+ error.FileNotFound => {
+ const name = try prompt.read("Username", allocator);
+ defer allocator.free(name);
+
+ const full_name = try prompt.read("Full Name", allocator);
+ defer allocator.free(full_name);
+
+ const birthday = try prompt.read("Birthday", allocator);
+ defer allocator.free(birthday);
+
+ const password = try prompt.read("Password", allocator);
+ defer allocator.free(password);
+
+ try self.dir.makeDir("user");
+
+ var user: User = try .new(
+ &self,
+ name,
+ full_name,
+ birthday,
+ password,
+ true,
+ allocator,
+ );
+ defer user.deinit();
+
+ try user.sync();
+ },
+ else => return err,
+ };
+
+ return self;
+}
+
+pub fn deinit(self: *Self) void {
+ self.dir.close();
+ self.* = undefined;
+}
diff --git a/src/storage/user.zig b/src/storage/user.zig
new file mode 100644
index 0000000..c981661
--- /dev/null
+++ b/src/storage/user.zig
@@ -0,0 +1,133 @@
+const std = @import("std");
+const Storage = @import("root.zig");
+
+const Self = @This();
+
+pub const UserInfo = struct {
+ name: []const u8,
+ full_name: []const u8,
+ birthday: []const u8,
+ hash: []const u8,
+ is_admin: bool,
+};
+
+dir: std.fs.Dir,
+info: UserInfo,
+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);
+ errdefer arena.deinit();
+ const allocator = arena.allocator();
+
+ var user_dir = try storage.dir.openDir("user", .{});
+ defer user_dir.close();
+
+ var dir = try user_dir.openDir(name, .{});
+ errdefer dir.close();
+
+ const file = try dir.openFile("info.json", .{});
+ defer file.close();
+ const content = try file.readToEndAlloc(allocator, std.math.maxInt(usize));
+
+ const info = try std.json.parseFromSliceLeaky(UserInfo, allocator, content, .{});
+
+ return .{
+ .dir = dir,
+ .arena = arena,
+ .info = info,
+ };
+}
+
+pub fn new(
+ storage: *Storage,
+ name: []const u8,
+ full_name: []const u8,
+ birthday: []const u8,
+ password: []const u8,
+ is_admin: bool,
+ inner_allocator: std.mem.Allocator
+) !Self {
+ var arena: std.heap.ArenaAllocator = .init(inner_allocator);
+ const allocator = arena.allocator();
+ errdefer arena.deinit();
+
+ var user_dir = try storage.dir.openDir("user", .{});
+ defer user_dir.close();
+
+ try user_dir.makeDir(name);
+ var dir = try user_dir.openDir(name, .{});
+ errdefer dir.close();
+
+ var file = try dir.createFile("info.json", .{});
+ file.close();
+
+ 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
+ );
+
+ const info: UserInfo = .{
+ .name = name,
+ .full_name = full_name,
+ .birthday = birthday,
+ .hash = hash,
+ .is_admin = is_admin,
+ };
+
+
+ return .{
+ .dir = dir,
+ .arena = arena,
+ .info = info,
+ };
+}
+
+pub fn sync(self: *Self) !void {
+ const file = try self.dir.openFile("info.json", .{ .mode = .write_only });
+ defer file.close();
+
+ var buffer: [1024]u8 = undefined;
+ var writer = file.writer(&buffer);
+
+ var stringify = std.json.Stringify {
+ .writer = &writer.interface,
+ .options = .{ .whitespace = .indent_2 }
+ };
+
+ try stringify.write(self.info);
+ try writer.interface.flush();
+}
+
+pub fn check_password(self: *Self, password: []const u8) bool {
+ std.crypto.pwhash.bcrypt.strVerify(self.info.hash, password, .{
+ .silently_truncate_password = false,
+ }) catch return false;
+ return true;
+}
+
+pub fn deinit(self: *Self) void {
+ self.dir.close();
+ self.arena.deinit();
+ self.* = undefined;
+}
+
+pub fn exists(storage: *Storage, name: []const u8) bool {
+ var user = storage.dir.openDir("user", .{}) catch return false;
+ defer user.deinit();
+
+ user.access(name) catch return false;
+ return true;
+}
diff --git a/static/api/auth.js b/static/api/auth.js
new file mode 100644
index 0000000..e663d88
--- /dev/null
+++ b/static/api/auth.js
@@ -0,0 +1,12 @@
+import * as rest from './rest.js';
+
+
+export async function login(user, password) {
+ const response = await rest.post('/api/auth/login', { user, password });
+ return response?.success ?? false;
+}
+
+export async function is_first_login(user) {
+ const response = await rest.post('/api/auth/first-login', { user });
+ return response?.is_first ?? false;
+}
diff --git a/static/api/images.js b/static/api/images.js
new file mode 100644
index 0000000..915aae6
--- /dev/null
+++ b/static/api/images.js
@@ -0,0 +1,20 @@
+import * as sfw from 'sfw';
+const { Input } = sfw.element.native;
+
+export async function upload_to_timeline() {
+ const input = Input.new({
+ type: 'file',
+ multiple: true,
+ accept: 'image/jpeg',
+ })
+ input.click();
+}
+
+export async function upload_to_profile() {
+ const input = Input.new({
+ type: 'file',
+ multiple: false,
+ accept: 'image/jpeg',
+ })
+ input.click();
+}
diff --git a/static/api/index.js b/static/api/index.js
index 50b9b26..07b484d 100644
--- a/static/api/index.js
+++ b/static/api/index.js
@@ -1,11 +1,2 @@
-import * as sfw from 'sfw';
-const { Input } = sfw.element.native;
-
-export async function upload_images() {
- const input = Input.new({
- type: 'file',
- multiple: true,
- accept: 'image/jpeg',
- })
- input.click();
-}
+export * as images from './images.js';
+export * as auth from './auth.js';
diff --git a/static/api/rest.js b/static/api/rest.js
new file mode 100644
index 0000000..b314615
--- /dev/null
+++ b/static/api/rest.js
@@ -0,0 +1,22 @@
+
+async function handle_fetch(promise) {
+ const result = await promise;
+ const json = await result.json();
+
+ if (!result.ok) {
+ throw json;
+ }
+
+ return json;
+}
+
+export function get(url) {
+ return handle_fetch(fetch(url));
+}
+
+export async function post(url, body) {
+ return handle_fetch(fetch(url, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }));
+}
diff --git a/static/icons/index.js b/static/icons/index.js
index 08ab7ea..3562754 100644
--- a/static/icons/index.js
+++ b/static/icons/index.js
@@ -11,6 +11,7 @@ const icons = [
'add',
'edit',
'check',
+ 'next',
];
const target = {
diff --git a/static/icons/next.js b/static/icons/next.js
new file mode 100644
index 0000000..9728ee7
--- /dev/null
+++ b/static/icons/next.js
@@ -0,0 +1,3 @@
+export default `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-right-short" viewBox="0 0 16 16">
+ <path fill-rule="evenodd" d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"/>
+</svg>`;
diff --git a/static/index.js b/static/index.js
index 870e3bb..c6388eb 100644
--- a/static/index.js
+++ b/static/index.js
@@ -29,15 +29,28 @@ const image_viewer = ImageViewer.new();
'/images/0010.jpg',
].forEach(url => image_viewer.add(url))
+
const login = LoginView.new({
- onlogin: () => {
- document.body.innerHTML = '';
- document.body.append(
- main,
- search,
- month_select,
- )
- }
+ onlogin: async (user, password) => {
+ if (await api.auth.login(user, password)) {
+ document.body.innerHTML = '';
+ document.body.append(
+ main,
+ search,
+ month_select,
+ )
+ } else {
+ login.comment = 'Incorrect username or password.';
+ }
+ },
+
+ onpassword: async (user) => {
+ if (await api.auth.is_first_login(user)) {
+ login.comment = 'Please enter a new password.';
+ } else {
+ login.comment = '';
+ }
+ },
});
const search = Search.new({
@@ -73,7 +86,7 @@ const main = MainView.new({
main.active_view = image_viewer;
month_select.show();
},
- onupload: () => api.upload_images(),
+ onupload: () => api.images.upload_to_timeline(),
onshuffle: () => {
main.active_kind = MainView.Kind.home;
main.active_view = shuffle;
diff --git a/static/pages/login/index.css b/static/pages/login/index.css
index 8a59d83..aa6726e 100644
--- a/static/pages/login/index.css
+++ b/static/pages/login/index.css
@@ -12,12 +12,52 @@
transform: translate(-50%, -50%);
max-width: 300px;
width: 100%;
- padding: 20px;
+ padding: 0px;
box-shadow: #223223aa 1px 1px 4px;
border-radius: var(--border-radius);
+ z-index: 2000;
+ display: grid;
+ grid-template-columns: auto 30px;
background: #ffffff99;
backdrop-filter: blur(10px);
- z-index: 2000;
+}
+
+#box input {
+ border-top-right-radius: 0px;
+ border-bottom-right-radius: 0px;
+ background: #0000;
+}
+
+#box button {
+ border-top-left-radius: 0px;
+ border-bottom-left-radius: 0px;
+ background: var(--page-background);
+ color: var(--fg);
+ padding: 5px;
+ background: #0000;
+}
+
+#username {
+ position: absolute;
+ top: calc(50% - 40px);
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 10000;
+ user-select: none;
+ color: var(--fg-primary);
+ font-size: 0.8em;
+ cursor: pointer;
+}
+#comment {
+ position: absolute;
+ top: calc(50% + 40px);
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 10000;
+ user-select: none;
+ color: var(--primary);
+ font-size: 0.8em;
+ cursor: pointer;
}
#title {
@@ -88,31 +128,6 @@
z-index: -2;
animation: right-bubble 1s normal forwards ease;
}
-
-#form {
- display: grid;
- grid-template-columns: 100px auto;
- max-width: 300px;
- width: 100%;
- margin: 0;
- margin-bottom: 20px;
- border-radius: 3px;
- gap: 10px;
-}
-
-#form input {
- width: 100%;
-}
-
-#form label {
- margin: auto 0px;
- font-weight: bold;
-}
-
-button {
- width: 100%;
-}
-
#subtitle {
position: absolute;
bottom: 50px;
diff --git a/static/pages/login/index.js b/static/pages/login/index.js
index fc14dbf..07c0aa2 100644
--- a/static/pages/login/index.js
+++ b/static/pages/login/index.js
@@ -1,42 +1,53 @@
import * as sfw from 'sfw';
const { Div, Label, H1: Title, Input, Button } = sfw.element.native;
+import icons from '../../icons/index.js';
+
const css = await sfw.css(import.meta.url, './index.css');
export default class LoginView extends sfw.element.Container {
+ #input
#user
- #password
+ #comment
constructor() {
super({ css });
this.onlogin = () => {};
+ this.onpassword = () => {};
+
+ this.#user = null;
this.body.append(
Div.new({
id: 'container',
children: [
Div.new({ id: 'title', innerText: 'Memora' }),
+ this.#user = Div.new({
+ id: 'username',
+ onclick: () => this.#reset(),
+ }),
Div.new({
id: 'box',
children: [
-
- Div.new({
- id: 'form',
- children: [
- Label.new({ innerText: 'User' }),
- this.#user = Input.new({ }),
-
- Label.new({ innerText: 'Password' }),
- this.#password = Input.new({ type: 'password' }),
- ]
+ this.#input = Input.new({
+ placeholder: 'Username',
+ onkeydown: (e) => {
+ if (e.key === 'Enter') {
+ return this.#next();
+ }
+ },
}),
Button.new({
- innerText: 'Login',
- onclick: () => this.onlogin(this.#user.value, this.#password.value),
+ children: [ icons.next ],
+ onclick: () => this.#next(),
}),
]
}),
+ this.#comment = Div.new({
+ id: 'comment',
+ onclick: () => this.#reset(),
+ }),
Div.new({
id: 'subtitle',
innerText: 'Where nostalgia is home.',
@@ -45,4 +56,33 @@ export default class LoginView extends sfw.element.Container {
})
);
}
+
+ async #next() {
+ if (this.#input.value === '') return;
+
+ if (this.#user.innerText) {
+ await this.onlogin(this.#user.innerText, this.#input.value);
+ this.#user.innerText = '';
+ this.#input.value = '';
+ this.#input.placeholder = 'Username';
+ this.#input.type = 'text';
+ } else {
+ await this.onpassword(this.#input.value);
+ this.#user.innerText = this.#input.value;
+ this.#input.value = '';
+ this.#input.placeholder = 'Password';
+ this.#input.type = 'password';
+ }
+ }
+
+ #reset() {
+ this.#user.innerText = '';
+ this.#input.value = '';
+ this.#input.placeholder = 'Username';
+ this.#input.type = 'text';
+ }
+
+ set comment(value) {
+ this.#comment.innerText = value;
+ }
}
diff --git a/static/pages/settings/index.css b/static/pages/settings/index.css
index 5128c18..eb718ab 100644
--- a/static/pages/settings/index.css
+++ b/static/pages/settings/index.css
@@ -7,6 +7,8 @@
display: flex;
flex-flow: column;
gap: 20px;
+ max-width: 700px;
+ margin: auto;
}
#profile-image {
diff --git a/static/pages/settings/index.js b/static/pages/settings/index.js
index da30ba7..6a0e231 100644
--- a/static/pages/settings/index.js
+++ b/static/pages/settings/index.js
@@ -45,7 +45,7 @@ export default class SettingsView extends sfw.element.Container {
}),
Div.new({
id: 'logout',
- innerText: 'Log-out',
+ innerText: 'Logout',
onclick: () => this.onlogout(),
}),
Div.new({