minor changes

This commit is contained in:
Yuzucchii 2025-06-11 17:43:50 -05:00
parent e9f81a8ad0
commit e8171642ef
Signed by: yuzucchii
SSH Key Fingerprint: SHA256:3hT0Nn/790kQI9VFVQ2Kfh3Ma3JZe2pST86n1T5G7ww
43 changed files with 1160 additions and 2892 deletions

2
.gitignore vendored
View File

@ -15,4 +15,4 @@ bundle.tar.gz
log/
.env.*
.env
public/new-styles.css
file.log

View File

@ -1,2 +0,0 @@
npx tailwind --output public/new-styles.css
zig build -Denvironment=production install

View File

@ -3,13 +3,14 @@ const jetzig = @import("jetzig");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = .ReleaseSafe;
const optimize = .ReleaseFast;
const exe = b.addExecutable(.{
.name = "yuzucchiidotxyz",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize
.optimize = optimize,
.use_llvm = false,
});
const options = b.addOptions();
@ -29,9 +30,7 @@ pub fn build(b: *std.Build) !void {
exe.root_module.addImport("uuid", uuid.module("uuid"));
// ^ Add all dependencies before `jetzig.jetzigInit()` ^
try jetzig.jetzigInit(b, exe, .{
.zmpl_version = .v2
});
try jetzig.jetzigInit(b, exe, .{ .zmpl_version = .v2 });
b.installArtifact(exe);

View File

@ -29,8 +29,8 @@
.hash = "uuid-0.3.0-oOieIYF1AAA_BtE7FvVqqTn5uEYTvvz7ycuVnalCOf8C",
},
.jetzig = .{
.url = "git+https://git.yuzucchii.xyz/yuzucchii/jetzig#6baa8c114ad739e5461584dfc79df5b7792557bf",
.hash = "jetzig-0.0.0-IpAgLURbDwD5jrmRznyPbGcMGFusxX1xXU1_FVFozIBL",
.url = "https://github.com/jetzig-framework/jetzig/archive/1cb27ffec8fb648a30a9aa65c1e6128cf967a2f8.tar.gz",
.hash = "jetzig-0.0.0-IpAgLYuEDwAjm-yxXp2A6efoieUq3lSYgsFq2g9x-oB6",
},
},
.paths = .{

View File

@ -2,12 +2,10 @@ pub const database = .{
.development = .{
.adapter = .postgresql,
.hostname = "localhost",
.port = 5432,
},
.testing = .{
.adapter = .postgresql,
},
.production = .{
.adapter = .postgresql,
.hostname = "postgres"
},
.production = .{ .adapter = .postgresql, .hostname = "postgres" },
};

2409
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
{
"name": "yuzucchii.xyz",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^3.4.17"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.5"
}
}

BIN
public/agrees.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
public/cc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

BIN
public/happy.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/miku1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/miku2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

BIN
public/miku3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

280
public/styles.css Normal file
View File

@ -0,0 +1,280 @@
html {
font-size: 62.5%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
body {
background-color: #f9f9f9;
}
body .container {
max-width: 38em;
font-size: 1.8rem;
line-height: 1.618;
margin: auto;
color: #4a4a4a;
padding: 13px;
}
@media (max-width: 684px) {
body {
font-size: 1.53rem;
}
}
@media (max-width: 382px) {
body {
font-size: 1.35rem;
}
}
.page-content {
}
.main-menu {
display: flex;
text-decoration: none;
list-style: none;
justify-content: center;
padding: 0;
}
.main-menu a {
padding: 30px;
text-align: center;
}
.main-menu p {
margin-top: 0;
margin-bottom: 0;
}
.main-menu a:hover {
text-decoration: none;
border-bottom: none;
}
.main-menu li:hover {
text-decoration: none;
cursor: pointer;
}
.main-menu img {
width: 100px;
height: 100px;
margin-top: 2.5em;
margin-bottom: 0;
}
footer {
max-width: 100%;
height: 130px;
width: 100%;
font-size: 16px;
overflow: hidden;
max-width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
footer img {
margin-right: 10px;
}
footer h5 {
padding: 0 5px 5px;
color: 656565;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
font-weight: 700;
margin-top: 3rem;
margin-bottom: 1.5rem;
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
word-break: break-word;
}
h1 {
font-size: 2.35em;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.75em;
}
h4 {
font-size: 1.5em;
}
h5 {
font-size: 1.25em;
}
h6 {
font-size: 1em;
}
p {
margin-top: 0px;
margin-bottom: 2.5rem;
}
small, sub, sup {
font-size: 75%;
}
a {
text-decoration: none;
color: #1d7484;
}
a:visited {
color: #144f5a;
}
a:hover {
color: #982c61;
border-bottom: 2px solid #4a4a4a;
}
ul {
padding-left: 1.4em;
margin-top: 0px;
margin-bottom: 2.5rem;
}
li {
margin-bottom: 0.4em;
}
blockquote {
margin-left: 0px;
margin-right: 0px;
padding-left: 1em;
padding-top: 0.8em;
padding-bottom: 0.8em;
padding-right: 0.8em;
border-left: 5px solid #1d7484;
margin-bottom: 2.5rem;
background-color: #f1f1f1;
}
blockquote p {
margin-bottom: 0;
}
img, video {
height: auto;
max-width: 100%;
margin-top: 0px;
margin-bottom: 2.5rem;
}
blockquote p {
margin-bottom: 0;
}
img, video {
height: auto;
max-width: 100%;
margin-top: 0px;
margin-bottom: 2.5rem;
}
/* Pre and Code */
pre {
background-color: #f1f1f1;
display: block;
padding: 1em;
overflow-x: auto;
margin-top: 0px;
margin-bottom: 2.5rem;
font-size: 0.9em;
}
code, kbd, samp {
font-size: 0.9em;
padding: 0 0.5em;
background-color: #f1f1f1;
white-space: pre-wrap;
}
pre > code {
padding: 0;
background-color: transparent;
white-space: pre;
font-size: 1em;
}
/* Buttons, forms and input */
input, textarea {
border: 1px solid #4a4a4a;
}
input:focus, textarea:focus {
border: 1px solid #1d7484;
}
textarea {
width: 100%;
}
.button, button, input[type=submit], input[type=reset], input[type=button], input[type=file]::file-selector-button {
display: inline-block;
padding: 5px 10px;
text-align: center;
text-decoration: none;
white-space: nowrap;
background-color: #1d7484;
color: #f9f9f9;
border-radius: 1px;
border: 1px solid #1d7484;
cursor: pointer;
box-sizing: border-box;
}
.button[disabled], button[disabled], input[type=submit][disabled], input[type=reset][disabled], input[type=button][disabled], input[type=file]::file-selector-button[disabled] {
cursor: default;
opacity: 0.5;
}
.button:hover, button:hover, input[type=submit]:hover, input[type=reset]:hover, input[type=button]:hover, input[type=file]::file-selector-button:hover {
background-color: #982c61;
color: #f9f9f9;
outline: 0;
}
.button:focus-visible, button:focus-visible, input[type=submit]:focus-visible, input[type=reset]:focus-visible, input[type=button]:focus-visible, input[type=file]::file-selector-button:focus-visible {
outline-style: solid;
outline-width: 2px;
}
textarea, select, input {
color: #4a4a4a;
padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
margin-bottom: 10px;
background-color: #f1f1f1;
border: 1px solid #f1f1f1;
border-radius: 4px;
box-shadow: none;
box-sizing: border-box;
}
textarea:focus, select:focus, input:focus {
border: 1px solid #1d7484;
outline: 0;
}
input[type=checkbox]:focus {
outline: 1px dotted #1d7484;
}
label, legend, fieldset {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}

View File

@ -6,8 +6,8 @@ pub const Blog = jetquery.Model(
struct {
id: i32,
title: []const u8,
blob: []const u8,
content: ?[]const u8,
blob: []const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
@ -25,3 +25,38 @@ pub const Session = jetquery.Model(
},
.{},
);
pub const BlogTag = jetquery.Model(
@This(),
"blog_tags",
struct {
id: i32,
blog_id: i32,
tag_id: i32,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.blog = jetquery.belongsTo(.Blog, .{}),
.tag = jetquery.belongsTo(.Tag, .{}),
},
},
);
pub const Tag = jetquery.Model(
@This(),
"tags",
struct {
id: i32,
name: []const u8,
emoji: ?[]const u8,
created_at: jetquery.DateTime,
updated_at: jetquery.DateTime,
},
.{
.relations = .{
.blog_tags = jetquery.hasMany(.BlogTag, .{}),
},
},
);

View File

@ -0,0 +1,30 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"tags",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.timestamps(.{}),
},
.{},
);
try repo.createTable(
"blog_tags",
&.{
t.primaryKey("id", .{}),
t.column("blog_id", .integer, .{ .reference = .{ "blogs", "id" } }),
t.column("tag_id", .integer, .{ .reference = .{ "tags", "id" } }),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("tags", .{});
}

View File

@ -0,0 +1,31 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.createTable(
"tags",
&.{
t.primaryKey("id", .{}),
t.column("name", .string, .{}),
t.timestamps(.{}),
},
.{},
);
try repo.createTable(
"blog_tags",
&.{
t.primaryKey("id", .{}),
t.column("blog_id", .integer, .{ .reference = .{ "blogs", "id" } }),
t.column("tag_id", .integer, .{ .reference = .{ "tags", "id" } }),
t.timestamps(.{}),
},
.{},
);
}
pub fn down(repo: anytype) !void {
try repo.dropTable("tags", .{});
try repo.dropTable("blog_tags", .{});
}

View File

@ -0,0 +1,15 @@
const std = @import("std");
const jetquery = @import("jetquery");
const t = jetquery.schema.table;
pub fn up(repo: anytype) !void {
try repo.alterTable("tags", .{
.columns = .{
.add = &.{
t.column("emoji", .string, .{ .optional = true }),
},
},
});
}
pub fn down(_: anytype) !void {}

View File

@ -1,43 +1,20 @@
<div class="max-w-3xl mx-auto px-4 py-12 font-sans text-win7-text bg-win7-bg rounded-xl shadow border border-win7-border">
<h2 class="text-3xl font-bold mb-6 pb-2 border-b border-win7-border">
About Me
</h2>
<div>
<h2>About Me</h2>
<p class="mb-4 text-lg leading-relaxed">
<p>
I perform sorcery in the software craftmanship endeavours, I do things, quiet things — mostly in text editors, occasionally in thought (I am able to play Balatro in my brain,) and sometimes in places unseen. This site is my collection unbestowed experiments, side quests, and digital breadcrumbs.
</p>
<p class="mb-4 text-lg leading-relaxed">
<p>
I do not sleep — I suspend. I do not dream — I garbage collect. My working hours are loosely tied to the gravitational pull of niche programming languages and the smell of old documentation, I talk about documentation like a synesthetic metaphor, I am very into skeuomorphism, so you will watch me build awful UIs.
</p>
<p class="mb-4 text-lg leading-relaxed">
<p>
You'll find posts here about software, personal projects, systems Im poking at, and the occasional half-serious exploration into how things break and why that is interesting.
</p>
<p class="text-lg leading-relaxed">
<p>
If you'd like to challenge me to a duel, discuss obscure software, or just say hello — I'm usually somewhere near Discord, or off refactoring something that didn't ask for it. May your stack traces be short.
</p>
<div class="my-6">
<button
onclick="copyIRC()"
class="bg-win7-link text-white px-4 py-2 rounded-md shadow hover:bg-win7-hover transition"
>
Meet me here on IRC.
</button>
<span id="irc-copied" class="ml-3 text-sm text-green-600 hidden">Copied!</span>
</div>
<script>
function copyIRC() {
const ircInfo = "irc://sv.yuzucchii.xyz:6697/#the-graveyard";
navigator.clipboard.writeText(ircInfo).then(() => {
const msg = document.getElementById("irc-copied");
msg.classList.remove("hidden");
setTimeout(() => msg.classList.add("hidden"), 2000);
});
}
</script>
</div>

View File

@ -1,5 +1,6 @@
const std = @import("std");
const jetzig = @import("jetzig");
const jetquery = jetzig.jetquery;
const Zmd = @import("zmd").Zmd;
const tokens = @import("zmd").tokens;
@ -7,75 +8,102 @@ const Node = @import("zmd").Node;
pub const layout = "panel";
/// Default fragments. Pass this to `Zmd.toHtml` or provide your own.
/// Formatters can be functions receiving an allocator, the current node, and the rendered
/// content, or a 2-element tuple containing the open and close for each node.
pub const Fragments = struct {
pub fn root(allocator: std.mem.Allocator, node: Node) ![]const u8 {
return try std.fmt.allocPrint(allocator, "{s}", .{node.content});
}
pub fn block(allocator: std.mem.Allocator, node: Node) ![]const u8 {
const style = "font-family: Monospace; background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);";
return if (node.meta) |meta|
std.fmt.allocPrint(allocator,
\\<pre class="language-{s}" style="{s}"><code>{s}</code></pre>
, .{ meta, style, node.content })
else
std.fmt.allocPrint(allocator,
\\<pre style="{s}"><code>{s}</code></pre>
, .{ style, node.content });
}
pub fn link(allocator: std.mem.Allocator, node: Node) ![]const u8 {
return std.fmt.allocPrint(allocator,
\\<a href="{s}" style="color: #1a73e8; text-decoration: none; font-weight: bold; transition: color 0.3s ease;">{s}</a>
, .{ node.href.?, node.title.? });
}
pub fn image(allocator: std.mem.Allocator, node: Node) ![]const u8 {
return std.fmt.allocPrint(allocator,
\\<img src="{s}" title="{s}" style="border: 4px solid #ccc; border-radius: 8px; max-width: 100%; height: auto;" />
, .{ node.href.?, node.title.? });
}
pub const h1 = .{ "<h1 style=\"font-family: 'Segoe UI', sans-serif; color: #333; border-bottom: 2px solid #ccc; padding-bottom: 10px;\">", "</h1>\n" };
pub const h2 = .{ "<h2 style=\"font-family: 'Segoe UI', sans-serif; color: #444; border-bottom: 1px solid #ddd; padding-bottom: 8px;\">", "</h2>\n" };
pub const h3 = .{ "<h3 style=\"font-family: 'Segoe UI', sans-serif; color: #555; padding-bottom: 6px;\">", "</h3>\n" };
pub const h4 = .{ "<h4 style=\"font-family: 'Segoe UI', sans-serif; color: #666; padding-bottom: 4px;\">", "</h4>\n" };
pub const h5 = .{ "<h5 style=\"font-family: 'Segoe UI', sans-serif; color: #777; padding-bottom: 2px;\">", "</h5>\n" };
pub const h6 = .{ "<h6 style=\"font-family: 'Segoe UI', sans-serif; color: #888; padding-bottom: 2px;\">", "</h6>\n" };
pub const bold = .{ "<b style=\"font-weight: 600;\">", "</b>" };
pub const italic = .{ "<i style=\"font-style: italic;\">", "</i>" };
pub const unordered_list = .{ "<ul style=\"list-style-type: disc; margin-left: 20px;\">", "</ul>" };
pub const ordered_list = .{ "<ol style=\"list-style-type: decimal; margin-left: 20px;\">", "</ol>" };
pub const list_item = .{ "<li style=\"margin-bottom: 5px;\">", "</li>" };
pub const code = .{ "<span style=\"font-family: Monospace; background: #f4f4f4; padding: 2px 5px; border-radius: 4px;\">", "</span>" };
pub const paragraph = .{ "\n<p style=\"line-height: 1.6; font-size: 1.1rem; color: #333;\">", "</p>\n" };
pub const default = .{ "", "" };
pub const Tag = struct {
id: i32,
name: []const u8,
emoji: ?[]const u8,
created_at: jetzig.jetquery.DateTime,
updated_at: jetzig.jetquery.DateTime,
};
/// Escape HTML entities.
pub fn escape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
const replacements = .{
.{ "&", "&amp;" },
.{ "<", "&lt;" },
.{ ">", "&gt;" },
};
pub const Blog = struct {
id: i32,
title: []const u8,
content: ?[]const u8,
blob: []const u8,
created_at: jetzig.jetquery.DateTime,
updated_at: jetzig.jetquery.DateTime,
tags: ?[]const Tag,
};
var output = input;
inline for (replacements) |replacement| {
output = try std.mem.replaceOwned(u8, allocator, output, replacement[0], replacement[1]);
pub fn fetchBlogs(allocator: std.mem.Allocator, req: *jetzig.Request) ![]Blog {
const tag_query = jetzig.database.Query(.Tag)
.select(.{})
.include(.blog_tags, .{});
var blogs: std.AutoArrayHashMapUnmanaged(i32, Blog) = .empty;
const tags = try req.repo.all(tag_query);
for (tags) |tag| {
for (tag.blog_tags) |blog_tag| {
// fetch blog
const blog = try fetchBlog(allocator, req, blog_tag.blog_id);
try blogs.put(allocator, blog.id, blog);
}
}
return output;
return blogs.values();
}
pub fn fetchBlog(allocator: std.mem.Allocator, req: *jetzig.Request, id: i32) !Blog {
const query = jetzig.database.Query(.Blog).find(id);
const blog = try req.repo.execute(query) orelse return error.BlogNotFound;
return .{
.id = blog.id,
.title = blog.title,
.content = blog.content,
.blob = blog.blob,
.created_at = blog.created_at,
.updated_at = blog.updated_at,
.tags = blk: {
const blog_tag_query = jetzig.database.Query(.BlogTag)
.findBy(.{ .blog_id = blog.id });
if (try req.repo.execute(blog_tag_query)) |blog_tag| {
var found: std.ArrayList(Tag) = .init(allocator);
const tag_query = jetzig.database.Query(.Tag)
.where(.{ .id = blog_tag.tag_id });
const tags = try req.repo.all(tag_query);
for (tags) |tag| {
try found.append(.{
.id = tag.id,
.name = tag.name,
.emoji = tag.emoji,
.created_at = tag.created_at,
.updated_at = tag.updated_at,
});
}
break :blk try found.toOwnedSlice();
} else {
break :blk null;
}
},
};
}
pub fn fetchTag(id: i32, req: *jetzig.Request) !Tag {
const query = jetzig.database.Query(.Tag)
.find(id);
const tag = try req.repo.execute(query) orelse return error.TagNotFound;
return .{
.id = tag.id,
.name = tag.name,
.emoji = tag.emoji,
.created_at = tag.created_at,
.updated_at = tag.updated_at,
};
}
pub fn index(request: *jetzig.Request) !jetzig.View {
const query = jetzig.database.Query(.Blog).orderBy(.{.created_at = .desc});
const blogs = try request.repo.all(query);
var root = try request.data(.object);
const blogs = try fetchBlogs(request.allocator, request);
try root.put("blogs", blogs);
const cookies = try request.cookies();
@ -94,36 +122,25 @@ pub fn index(request: *jetzig.Request) !jetzig.View {
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
const query = jetzig.database.Query(.Blog).find(id);
const blog = try request.repo.execute(query) orelse return request.fail(.not_found);
const Blog = struct {
id: i32,
title: []const u8,
content: ?[]const u8,
created_at: jetzig.jetquery.DateTime,
updated_at: jetzig.jetquery.DateTime,
};
var zmd = Zmd.init(request.allocator);
defer zmd.deinit();
var blog = try fetchBlog(
request.allocator,
request,
try std.fmt.parseInt(i32, id, 10),
);
try zmd.parse(blog.content orelse {
return request.fail(.unprocessable_entity);
});
const blob = try zmd.toHtml(Fragments);
const blob = try zmd.toHtml(@import("zmd").html.DefaultFragments);
const eb = Blog{
.id = blog.id,
.title = blog.title,
.content = blob,
.created_at = blog.created_at,
.updated_at = blog.updated_at,
};
blog.content = blob;
var root = try request.data(.object);
try root.put("blog", eb);
try root.put("blog", blog);
const cookies = try request.cookies();
const allowed = blk: {
@ -184,17 +201,51 @@ pub fn post(request: *jetzig.Request) !jetzig.View {
return request.fail(.unprocessable_entity);
};
const tag_str = params.getT(.string, "tags") orelse {
return request.fail(.unprocessable_entity);
};
try request.repo.insert(.Blog, .{
.title = title,
.blob = preview,
.content = content,
});
var tag_names = std.mem.splitScalar(u8, tag_str, ',');
while (tag_names.next()) |tag_name| {
// check if tag exists
const tag_query = jetzig.database.Query(.Tag)
.findBy(.{ .name = tag_name });
const tag = blk: {
break :blk try request.repo.execute(tag_query) orelse {
// tag does not exist, create it
try request.repo.insert(.Tag, .{ .name = tag_name });
break :blk try request.repo.execute(tag_query) orelse {
return request.fail(.unprocessable_entity);
};
};
};
try request.repo.insert(.BlogTag, .{
.tag_id = tag.id,
.blog_id = blk: {
const blog_query = jetzig.database.Query(.Blog)
.findBy(.{ .title = title });
if (try request.repo.execute(blog_query)) |blog| {
break :blk blog.id;
} else {
return request.fail(.not_found);
}
},
});
}
return request.redirect("/blogs", .moved_permanently);
} else {
return request.fail(.not_found);
}
// session is not valid
return request.fail(.not_found);
}
pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
@ -244,12 +295,36 @@ pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
const blog_query = jetzig.database.Query(.Blog)
.find(try std.fmt.parseInt(i32, id, 10));
const blog = request.repo.execute(blog_query) catch {
const blog = try request.repo.execute(blog_query) orelse {
return request.fail(.not_found);
};
const root = try request.data(.object);
try root.put("allowed", true);
try root.put("blog_tags", blk: {
var tagstring: std.ArrayList(u8) = .init(request.allocator);
errdefer tagstring.deinit();
const blog_tag_query = jetzig.database.Query(.BlogTag)
.where(.{ .blog_id = blog.id });
const blog_tags = try request.repo.all(blog_tag_query);
for (blog_tags) |blog_tag| {
const tag_query = jetzig.database.Query(.Tag)
.find(blog_tag.tag_id);
const tag = try request.repo.execute(tag_query) orelse {
return request.fail(.not_found);
};
try tagstring.appendSlice(tag.name);
try tagstring.append(',');
}
break :blk tagstring.toOwnedSlice();
});
try root.put("blog", blog);
return request.render(.ok);
@ -265,12 +340,41 @@ pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
const title = params.getT(.string, "title") orelse blog.title;
const content = params.getT(.string, "content") orelse blog.content;
const preview = params.getT(.string, "preview") orelse blog.blob;
const tags = params.getT(.string, "tags") orelse "";
// query the blogpost first
const blog_query = jetzig.database.Query(.Blog)
.update(.{ .title = title, .blob = preview, .content = content })
.where(.{ .id = try std.fmt.parseInt(i32, id, 10) });
const prune = jetzig.database.Query(.BlogTag)
.delete()
.where(.{ .blog_id = blog.id });
try request.repo.execute(prune);
var tag_names = std.mem.splitScalar(u8, tags, ',');
while (tag_names.next()) |tag_name| {
const tag_query = jetzig.database.Query(.Tag)
.findBy(.{ .name = tag_name });
const tag = blk: {
break :blk try request.repo.execute(tag_query) orelse {
// tag does not exist, create it
try request.repo.insert(.Tag, .{ .name = tag_name });
break :blk try request.repo.execute(tag_query) orelse {
return request.fail(.unprocessable_entity);
};
};
};
// insert the tag
try request.repo.insert(.BlogTag, .{
.tag_id = tag.id,
.blog_id = blog.id,
});
}
// update the blogpost
try request.repo.execute(blog_query);

View File

@ -1,36 +1,44 @@
<div class="max-w-2xl mx-auto mt-10 p-6 bg-white border border-win7-border shadow-md rounded-2xl font-sans text-win7-text">
<form action="/blogs/{{.blog.id}}/edit" method="POST" class="space-y-6">
<div>
<form action="/blogs/{{.blog.id}}/edit" method="POST">
<div>
<label for="title" class="block text-sm font-medium mb-1">Title</label>
<label for="title">Title</label>
<input
type="text"
name="title"
id="title"
value="{{.blog.title}}"
required
class="mt-1 w-full px-4 py-2 bg-white border border-win7-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
/>
</div>
<div>
<label for="content" class="block text-sm font-medium mb-1">Content</label>
<label for="tags">Tags (comma-separated)</label>
<input
type="text"
name="tags"
id="tags"
value="{{.blog_tags}}"
required
/>
</div>
<div>
<label for="content">Content</label>
<textarea
name="content"
id="content"
rows="8"
required
class="mt-1 w-full px-4 py-2 bg-white border border-win7-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
>{{.blog.content}}</textarea>
</div>
<div>
<label for="preview" class="block text-sm font-medium mb-1">Preview</label>
<label for="preview">Preview</label>
<textarea
name="preview"
id="preview"
rows="3"
class="mt-1 w-full px-4 py-2 bg-white border border-win7-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
>{{.blog.blob}}</textarea>
</div>
@ -38,7 +46,6 @@
<input
type="submit"
value="Save Changes"
class="px-4 py-2 bg-win7-link text-white font-semibold rounded-md shadow hover:bg-win7-hover transition"
/>
</div>
</form>

View File

@ -0,0 +1,40 @@
<div>
@for ($.blogs) |blog| {
<div>
<a href="/blogs/{{blog.id}}">
{{zmpl.fmt.datetime(blog.get("created_at"), "%Y-%m-%d | ")}} {{blog.title}}
</a>
@if ($.allowed)
<div>
<a href="/blogs/{{blog.id}}/edit">Edit</a>
<button id="delete-post-{{blog.id}}">Delete</button>
</div>
@end
</div>
}
@if ($.allowed)
<br>
<div><a href="/blogs/new">+ New Blog</a></div>
@end
</div>
@if ($.allowed)
<script>
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[id^="delete-post-"]').forEach(button => {
button.addEventListener("click", () => {
const id = button.id.replace("delete-post-", "");
fetch(`/blogs/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
})
.then(() => window.location.reload())
.catch(err => console.error("Failed to delete post:", err));
});
});
});
</script>
@end

View File

@ -1,15 +1,18 @@
<div class="relative w-full px-6 py-12 bg-white shadow-xl shadow-slate-700/10 ring-1 ring-gray-900/5 md:max-w-3xl md:mx-auto lg:max-w-4xl lg:pt-16 lg:pb-28">
<h1 class="text-4xl font-bold text-gray-900 mt-6 mb-4" style="text-shadow: 1px 1px 5px rgba(0,0,0,0.1);">{{.blog.title}}</h1>
<article class="prose lg:prose-xl">
<div>
<h1>{{.blog.title}}</h1>
<span>Tags:
@zig {
@if ($.blog.tags) |tags|
@for (tags) |tag| {
<a href="/blogs/tags/{{tag.name}}">{{tag.name}}</a>
}
@else
<a href="/blogs">None</a>
@end
}
</span>
<article>
{{zmpl.fmt.raw(zmpl.ref("blog.content"))}}
</article>
<button class="fixed bottom-6 right-6 bg-blue-500 text-white p-3 rounded-full shadow-lg hover:bg-blue-600" id="back-to-top">
</button>
<script>
document.getElementById('back-to-top').addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
</script>
</article>
</div>

View File

@ -1,58 +1,40 @@
@args allowed: bool
<div class="max-w-3xl mx-auto mt-10 space-y-6 font-sans">
@for (.blogs) |blog| {
<div class="p4 bg-win7-bg border border-win7-border rounded-xl px-5 py-4 shadow-sm hover:shadow-md transition duration-150">
<div class="flex justify-between items-center">
<a href="/blogs/{{blog.id}}" class="text-xl font-semibold text-win7-link hover:underline transition">{{blog.title}}</a>
@if ($.allowed)
<div class="flex items-center gap-4">
<a href="/blogs/{{blog.id}}/edit"
class="text-sm text-win7-link hover:underline transition"
>
Edit
</a>
<button
id="delete-post-{{blog.id}}"
class="text-sm text-win7-red hover:text-red-800 transition"
>
Delete
</button>
</div>
@end
</div>
<p class="text-xs text-[#4a5c75] mt-2">{{zmpl.fmt.datetime(blog.get("created_at"), "%Y-%m-%d %H:%M")}}</p>
</div>
}
@if ($.allowed)
<div class="pt-4">
<a href="/blogs/new"
class="inline-block px-4 py-2 bg-win7-link text-white rounded-md hover:bg-win7-hover shadow transition duration-150">
+ New Blog
</a>
<div>
@for ($.blogs) |blog| {
<div>
<a href="/blogs/{{blog.id}}">
{{zmpl.fmt.datetime(blog.get("created_at"), "%Y-%m-%d | ")}} {{blog.title}}
</a>
@if ($.allowed)
<div>
<a href="/blogs/{{blog.id}}/edit">Edit</a>
<button id="delete-post-{{blog.id}}">Delete</button>
</div>
@end
@end
</div>
}
@if ($.allowed)
<br>
<div><a href="/blogs/new">+ New Blog</a></div>
@end
</div>
@if ($.allowed)
<script>
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[id^="delete-post-"]').forEach(button => {
button.addEventListener("click", () => {
const id = button.id.replace("delete-post-", "");
fetch(`/blogs/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
})
.then(() => window.location.reload())
.catch(err => console.error("Failed to delete post:", err));
});
});
});
</script>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[id^="delete-post-"]').forEach(button => {
button.addEventListener("click", () => {
const id = button.id.replace("delete-post-", "");
fetch(`/blogs/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
})
.then(() => window.location.reload())
.catch(err => console.error("Failed to delete post:", err));
});
});
});
</script>
@end

View File

@ -1,34 +1,41 @@
<div class="max-w-2xl mx-auto mt-10 p-6 bg-white border border-win7-border shadow-md rounded-2xl font-sans text-win7-text">
<form action="/blogs" method="POST" class="space-y-6">
<div>
<form action="/blogs" method="POST">
<div>
<label for="title" class="block text-sm font-medium mb-1">Title</label>
<label for="title">Title</label>
<input
name="title"
id="title"
required
class="mt-1 w-full px-4 py-2 bg-white border border-win7-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
/>
</div>
<div>
<label for="content" class="block text-sm font-medium mb-1">Content</label>
<label for="tags">Tags (comma-separated)</label>
<input
type="text"
name="tags"
id="tags"
required
/>
</div>
<div>
<label for="content">Content</label>
<textarea
name="content"
id="content"
rows="8"
required
class="mt-1 w-full px-4 py-2 bg-white border border-win7-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
></textarea>
</div>
<div>
<label for="preview" class="block text-sm font-medium mb-1">Preview</label>
<label for="preview">Preview</label>
<textarea
name="preview"
id="preview"
rows="3"
class="mt-1 w-full px-4 py-2 bg-white border border-win7-border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
></textarea>
</div>
@ -36,7 +43,6 @@
<input
type="submit"
value="Publish"
class="px-4 py-2 bg-win7-link text-white font-semibold rounded-md shadow hover:bg-win7-hover transition"
/>
</div>
</form>

View File

@ -1,3 +1 @@
<div>
<span>Content goes here</span>
</div>
<div><span>Content goes here</span></div>

View File

@ -1,30 +1,21 @@
@zig {
const links = .{
.{ .href = "https://yuzucchii.xyz", .title = "Home" },
.{ .href = "/rss", .title = "RSS" },
.{ .href = "https://codeberg.org/yuzu", .title = "Codeberg" },
.{ .href = "https://bsky.app/", .title = "Bluesky" },
.{ .href = "https://discord.gg/pvraBkepUP", .title = "Discord" },
};
const links = .{
.{ .href = "https://yuzucchii.xyz", .title = "Home" },
.{ .href = "/rss", .title = "RSS" },
.{ .href = "https://codeberg.org/yuzu", .title = "Codeberg" },
.{ .href = "https://bsky.app/profile/yuzucchii.bsky.social", .title = "Bluesky" },
.{ .href = "https://discord.gg/pvraBkepUP", .title = "Discord" },
};
}
<footer class="mt-12 bg-win7-bg border-t border-win7-border pt-6 text-center text-sm text-win7-text font-sans">
<div class="max-w-xl mx-auto px-4">
<div class="bg-warnbox-bg text-warnbox-text border border-warnbox-border rounded-md px-4 py-3 mb-6 shadow-inner">
<p class="text-sm md:text-base">
This website is not reliant on JavaScript. All scripts are optional and publicly available.
</p>
</div>
<div class="flex justify-center flex-wrap gap-4 mb-4 text-win7-link">
@zig {
<footer>
<img src="/cc.png" alt="CC from Code Geass" width="100px" height="100px"/>
<p>
@zig {
inline for (links) |link| {
<a href="{{link.href}}" class="hover:underline">{{link.title}}</a>
<a href="{{link.href}}">{{link.title}}</a>
}
}
</div>
<p class="text-xs text-[#4a5c75] pb-5">&copy; 2025 yuzucchii.xyz</p>
</div>
}
&copy; 2025 yuzucchii.xyz
</p>
</footer>

View File

@ -10,21 +10,21 @@
<meta property="og:site_name" content="yuzucchii.xyz" />
@if ($.title) |t|
<meta name="og:title" property="og:title" content="{{t}}" />
<meta name="og:title" property="og:title" content="{{t}}" />
@else
<meta name="og:title" property="og:title" content="yuzucchii.xyz" />
<meta name="og:title" property="og:title" content="yuzucchii.xyz" />
@end
@if ($.image) |img|
<meta name="og:image" content="{{img}}" />
<meta property="twitter:card" content="{{img}}" />
<meta name="twitter:image:src" property="twitter:image:src" content="{{img}}" />
<meta name="twitter:image" property="twitter:image" content="{{img}}" />
<meta name="og:image:alt" property="og:image:alt" content="alt text for your image" />
<meta name="og:image" content="{{img}}" />
<meta property="twitter:card" content="{{img}}" />
<meta name="twitter:image:src" property="twitter:image:src" content="{{img}}" />
<meta name="twitter:image" property="twitter:image" content="{{img}}" />
<meta name="og:image:alt" property="og:image:alt" content="alt text for your image" />
@end
@if ($.description) |desc|
<meta name="description" content="{{desc}}" />
<meta property="og:description" content="{{desc}}" />
<meta name="twitter:description" property="twitter:description" content="{{desc}}" />
<meta name="description" content="{{desc}}" />
<meta property="og:description" content="{{desc}}" />
<meta name="twitter:description" property="twitter:description" content="{{desc}}" />
@end

View File

@ -0,0 +1,20 @@
@args allowed: bool, description: ?[]const u8, title: ?[]const u8, img: ?[]const u8
<html>
<head>
@partial layouts/metadata
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/styles.css" type="text/css">
<title>yuzucchii.xyz</title>
</head>
<body>
<div class="container">
<main>
{{zmpl.content}}
</main>
</div>
@partial layouts/footer
</body>
</html>

View File

@ -1,44 +1,36 @@
@args allowed: bool, description: ?[]const u8, title: ?[]const u8, img: ?[]const u8
<html>
<head>
@partial layouts/metadata
<head>
@partial layouts/metadata
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/new-styles.css" />
<link rel="stylesheet" href="/styles.css" type="text/css">
<title>yuzucchii.xyz</title>
</head>
<body>
<nav class="bg-win7-bg shadow-sm border-b border-win7-border py-4 font-sans">
<div class="max-w-4xl mx-auto text-center">
<div class="text-2xl font-bold text-win7-text mb-2">
<a href="/" class="hover:text-win7-hover transition">yuzucchii.xyz</a>
</div>
<div class="flex flex-wrap justify-center gap-4 text-sm sm:text-base text-win7-link">
<a href="/blogs" class="hover:underline">Blog</a>
<a href="/about-me" class="hover:underline">About Me</a>
@if ($.allowed)
<a href="/logout" class="hover:underline">Logout</a>
@else
<a href="/login" class="hover:underline">Login</a>
@end
<a href="https://git.yuzucchii.xyz" class="hover:underline" rel="noopener">Gitea</a>
<a href="https://watcharr.yuzucchii.xyz" class="hover:underline">Watcharr</a>
<a href="/rss" class="hover:underline">RSS</a>
<a href="https://searx.yuzucchii.xyz" class="hover:underline">SearX</a>
<a href="/files" class="text-win7-red hover:underline">Files</a>
<a href="mailto:me@yuzucchii.xyz" class="hover:underline">E-mail</a>
</div>
</div>
</nav>
</head>
<body>
<div class="container">
<nav>
<div>
<a href="/">Home</a>
<a href="/blogs">Blog</a>
<a href="/about-me">About Me</a>
@if ($.allowed)
<a href="/logout">Logout</a>
@end
<a href="https://git.yuzucchii.xyz">Gitea</a>
<a href="/rss">RSS</a>
<a href="mailto:me@yuzucchii.xyz">E-mail</a>
</div>
</nav>
<!-- Page Content -->
<main class="max-w-3xl mx-auto px-4 mt-8">
<h1 class="text-3xl font-bold mb-6">Hello, I like doing things.</h1>
{{zmpl.content}}
</main>
<!-- Page Content -->
<main>
{{zmpl.content}}
</main>
</div>
@partial layouts/footer
</body>
@partial layouts/footer
</body>
</html>

View File

@ -1,35 +1,28 @@
<div class="min-h-screen flex items-center justify-center bg-win7-bg font-sans text-win7-text">
<form id="login-form" class="bg-white p-8 border border-win7-border rounded-2xl shadow-xl w-full max-w-sm space-y-6">
<h1 class="text-xl font-semibold text-center mb-2 text-win7-link">Admin Login</h1>
<div>
<form id="login-form">
<h1>Admin Login</h1>
<div>
<label for="username" class="block text-sm font-medium mb-1">Username</label>
<label for="username">Username</label>
<input
id="username"
name="username"
type="text"
required
class="w-full px-4 py-2 border border-win7-border rounded-md shadow-sm bg-white focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium mb-1">Discrete password</label>
<label for="password">Discrete password</label>
<input
id="password"
name="password"
type="password"
required
class="w-full px-4 py-2 border border-win7-border rounded-md shadow-sm bg-white focus:outline-none focus:ring-2 focus:ring-win7-hover focus:border-win7-hover transition"
/>
</div>
<button
type="submit"
class="w-full bg-win7-link text-white font-semibold py-2 rounded-md hover:bg-win7-hover shadow transition"
>
<button type="submit">
Login
</button>
</form>

View File

@ -1,39 +1,28 @@
<div>
<button id="logout-button">Logout</button>
</div>
<title>Login panel for admins</title>
<body>
<div>
<button
id="logout-button"
class="px-4 py-2 bg-white border border-gray-300 text-gray-800 rounded-md shadow-sm hover:bg-gray-100 hover:border-gray-400 transition-all"
>
Logout
</button>
</div>
<script>
document.getElementById('logout-button').addEventListener('click', function () {
fetch('/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'logout' })
})
.then(response => {
if (!response.ok) {
throw new Error(`Logout failed: ${response.status}`);
}
return response.json(); // if you expect JSON back
})
.then(data => {
console.log('Logout successful:', data);
// window.location.href = '/login'; // redirect if needed
})
.catch(error => {
console.error('Error during logout:', error);
});
});
</script>
</body>
<script>
document.getElementById('logout-button').addEventListener('click', function () {
fetch('/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'logout' })
})
.then(response => {
if (!response.ok) {
throw new Error(`Logout failed: ${response.status}`);
}
return response.json(); // if you expect JSON back
})
.then(data => {
console.log('Logout successful:', data);
window.location.href = '/login';
})
.catch(error => {
console.error('Error during logout:', error);
});
});
</script>

View File

@ -1,20 +1,22 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub const layout = "panel";
pub const layout = "minimal";
const Article = struct {
title: [255]u8,
blob: [255]u8
};
const Article = struct { title: [255]u8, blob: [255]u8 };
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var root = try data.object();
const cookies = try request.cookies();
const query = jetzig.database.Query(.Blog).orderBy(.{.created_at = .desc});
const query = jetzig.database.Query(.Blog).orderBy(.{ .created_at = .desc });
const tags = try request.repo.all(
jetzig.database.Query(.Tag).orderBy(.{ .name = .asc }),
);
const blogs = try request.repo.all(query);
try root.put("tags", tags);
try root.put("articles", blogs);
const allowed = blk: {

View File

@ -1,8 +1,8 @@
@args title: []const u8, id: i32, blob: []const u8
<div class="mb-4">
<h2 class="text-2xl font-semibold mb-2">{{title}}</h2>
<div class="text-gray-700">
{{blob}}
<div>
<a href="/blogs/{{id}}">{{title}}</a>
<div>
{{blob}}
</div>
<a href="/blogs/{{id}}" class="text-blue-600 underline">Read more</a>
<br>
</div>

View File

@ -0,0 +1,6 @@
@args name: []const u8, emoji: []const u8
<div>
<li>
<a href="/blogs/tags/{{name}}">{{emoji}} {{name}}</a>
</li>
</div>

View File

@ -1,24 +1,51 @@
@args articles: Zmpl.Array
<section class="max-w-3xl mx-auto px-4 py-8">
<!-- Banner -->
<div class="mb-8">
<img src="/new-banner.webp" alt="Banner" class="w-full rounded-xl shadow-md">
</div>
<section>
<!-- Links Section -->
<ul class="main-menu">
<li class="main-menu-item">
<a href="/">
<img src="/miku1.png" alt="Miku expressive" width="100px" height="100px"/>
<p>Home</p>
</a>
</li>
<li class="main-menu-item">
<a href="/about-me">
<img src="/miku2.png" alt="Miku cat" width="100px" height="100px"/>
<p>About</p>
</a>
<li class="main-menu-item">
<a href="/blogs">
<img src="/miku3.png" alt="Miku blogger" width="100px" height="100px"/>
<p>Blogs</p>
</a>
</li>
</ul>
<!-- Introduction -->
<p class="text-lg leading-relaxed mb-10 text-gray-700">
I created this website in order to store things I do. Dont be scared of the simple layout —
this website is featureful and full of things to explore. Please enjoy your visit.
</p>
<!-- Introduction -->
<p>
I created this website in order to store things I do. Dont be scared of the simple layout —
this website is featureful and full of things to explore. Please enjoy your visit.
</p>
<!-- Articles Section -->
<h2 class="text-2xl font-semibold mb-4 border-b pb-2">Latest Articles</h2>
<!-- Articles Section -->
<h2>Latest Articles</h2>
<div class="space-y-6">
@for (.articles) |article| {
<div class="p-4 border rounded-xl bg-white shadow-sm hover:shadow transition">
@partial root/article_blob(title: article.title, blob: article.blob, id: article.id)
</div>
}
</div>
<div>
@for (.tags) |tag| {
@if ($.tag.emoji) |emoji|
<div>
@partial root/tag_blob(name: tag.name, emoji: emoji)
</div>
@else
<div>
@partial root/tag_blob(name: tag.name, emoji: "")
</div>
@end
}
@for (.articles) |article| {
@partial root/article_blob(title: article.title, blob: article.blob, id: article.id)
}
</div>
</section>

157
src/app/views/tags.zig Normal file
View File

@ -0,0 +1,157 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub const layout = "panel";
const Blog = @import("blogs.zig").Blog;
const Tag = @import("blogs.zig").Tag;
pub fn filterBlogs(allocator: std.mem.Allocator, req: *jetzig.Request, tag_str: []const u8) ![]Blog {
var blogs: std.AutoArrayHashMapUnmanaged(i32, Blog) = .empty;
var tag_names = std.mem.splitScalar(u8, tag_str, ',');
while (tag_names.next()) |tag_name| {
const query = jetzig.database.Query(.Tag)
.findBy(.{ .name = tag_name })
.include(.blog_tags, .{});
const tag = try req.repo.execute(query) orelse continue;
for (tag.blog_tags) |blog_tag| {
const blog = try @import("blogs.zig").fetchBlog(allocator, req, blog_tag.blog_id);
try blogs.put(allocator, blog.id, blog);
}
}
std.debug.print("blogs: {any}\n", .{blogs.values()});
return blogs.values();
}
pub fn index(request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
const query = jetzig.database.Query(.Tag)
.orderBy(.{ .name = .asc });
const tags = try request.repo.all(query);
try root.put("tags", tags);
const cookies = try request.cookies();
const allowed = blk: {
const session = cookies.get("session") orelse break :blk false;
const session_query = jetzig.database.Query(.Session)
.findBy(.{ .session_id = session.value });
_ = request.repo.execute(session_query) catch break :blk false;
break :blk true;
};
try root.put("allowed", allowed);
return request.render(.ok);
}
pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object);
const blogs =
try filterBlogs(request.allocator, request, id);
try root.put("blogs", blogs);
const cookies = try request.cookies();
const allowed = blk: {
const session = cookies.get("session") orelse break :blk false;
const session_query = jetzig.database.Query(.Session)
.findBy(.{ .session_id = session.value });
_ = request.repo.execute(session_query) catch break :blk false;
break :blk true;
};
try root.put("allowed", allowed);
return request.render(.ok);
}
// tags/:id/edit
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
// check "session" cookie for authentication
const cookies = try request.cookies();
const session = cookies.get("session") orelse {
return request.fail(.not_found);
};
const session_query = jetzig.database.Query(.Session)
.findBy(.{ .session_id = session.value });
_ = request.repo.execute(session_query) catch {
return request.fail(.not_found);
};
switch (request.method) {
.GET => {
// get tag
const tag_query = jetzig.database.Query(.Tag)
.findBy(.{ .name = id });
const tag = request.repo.execute(tag_query) catch {
return request.fail(.not_found);
};
const root = try request.data(.object);
try root.put("allowed", true);
try root.put("tag", tag);
return request.render(.ok);
},
.POST => {
const params = try request.params();
const tag_query_original = jetzig.database.Query(.Tag)
.findBy(.{ .name = id });
if (try request.repo.execute(tag_query_original)) |tag| {
const name = params.getT(.string, "name") orelse tag.name;
const emoji = params.getT(.string, "emoji") orelse tag.emoji;
const tag_query = jetzig.database.Query(.Tag)
.update(.{ .name = name, .emoji = emoji })
.where(.{ .id = tag.id });
try request.repo.execute(tag_query);
return request.redirect("/tags", .moved_permanently);
} else {
return request.fail(.not_found);
}
},
else => return request.fail(.not_found),
}
}
pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View {
// check "session" cookie for authentication
const cookies = try request.cookies();
const session = cookies.get("session") orelse {
return request.fail(.not_found);
};
const session_query = jetzig.database.Query(.Session)
.findBy(.{ .session_id = session.value });
_ = request.repo.execute(session_query) catch {
return request.fail(.not_found);
};
const tag_query = jetzig.database.Query(.Tag)
.findBy(.{ .name = id });
if (try request.repo.execute(tag_query)) |tag| {
try request.repo.Query(.BlogTag)
.deleteAll()
//.where(.{ .tag_id = tag.id })
.execute(request.repo);
try request.repo.delete(tag);
}
return request.render(.ok);
}

View File

@ -0,0 +1,34 @@
<div>
<form action="/tags/{{.tag.name}}/edit" method="POST">
<div>
<label for="name">Name</label>
<input
type="text"
name="name"
id="name"
value="{{.tag.name}}"
required
/>
</div>
<div>
<label for="emoji">Emoji</label>
<input
type="text"
name="emoji"
id="emoji"
value="{{.tag.emoji}}"
required
/>
</div>
<div>
<input
type="submit"
value="Save Changes"
/>
</div>
</form>
</div>

View File

@ -0,0 +1,40 @@
<div>
@for ($.blogs) |blog| {
<div>
<a href="/blogs/{{blog.id}}">
{{zmpl.fmt.datetime(blog.get("created_at"), "%Y-%m-%d | ")}} {{blog.title}}
</a>
@if ($.allowed)
<div>
<a href="/blogs/{{blog.id}}/edit">Edit</a>
<button id="delete-post-{{blog.id}}">Delete</button>
</div>
@end
</div>
}
@if ($.allowed)
<br>
<div><a href="/blogs/new">+ New Blog</a></div>
@end
</div>
@if ($.allowed)
<script>
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[id^="delete-post-"]').forEach(button => {
button.addEventListener("click", () => {
const id = button.id.replace("delete-post-", "");
fetch(`/blogs/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
})
.then(() => window.location.reload())
.catch(err => console.error("Failed to delete post:", err));
});
});
});
</script>
@end

View File

@ -0,0 +1,37 @@
@for (.tags) |tag| {
@if ($.tag.emoji) |emoji|
<div>
@partial root/tag_blob(name: tag.name, emoji: emoji)
</div>
@else
<div>
@partial root/tag_blob(name: tag.name, emoji: "")
</div>
@end
@if ($.allowed)
<div>
<a href="/tags/{{tag.name}}/edit">Edit</a>
<button id="delete-tag-{{tag.name}}">Delete</button>
</div>
@end
}
@if ($.allowed)
<script>
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[id^="delete-tag-"]').forEach(button => {
button.addEventListener("click", () => {
const name = button.id.replace("delete-tag-", "");
fetch(`/tags/${name}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
})
.then(() => window.location.reload())
.catch(err => console.error("Failed to delete tag:", err));
});
});
});
</script>
@end

View File

@ -105,8 +105,11 @@ pub const jetzig_options = struct {
pub fn init(app: *jetzig.App) !void {
app.route(.GET, "/rss.xml", @import("app/views/rss.zig"), .index);
app.route(.GET, "/blogs/:id/edit" , @import("app/views/blogs.zig"), .edit);
app.route(.POST, "/blogs/:id/edit" , @import("app/views/blogs.zig"), .edit);
app.route(.GET, "/blogs/:id/edit", @import("app/views/blogs.zig"), .edit);
app.route(.POST, "/blogs/:id/edit", @import("app/views/blogs.zig"), .edit);
app.route(.GET, "/tags/:id/edit", @import("app/views/tags.zig"), .edit);
app.route(.POST, "/tags/:id/edit", @import("app/views/tags.zig"), .edit);
app.route(.GET, "/blogs/tags/:id", @import("app/views/tags.zig"), .get);
}
pub fn main() !void {

View File

@ -1,72 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@plugin "@tailwindcss/typography";
@config "../tailwind.config.js";
/* Base typography improvements */
@layer base {
body {
@apply bg-white text-gray-800 antialiased leading-relaxed;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
a {
@apply text-blue-600 hover:underline transition;
}
input, textarea, button {
@apply outline-none focus:ring focus:ring-blue-200 focus:ring-opacity-50;
}
textarea {
@apply resize-y;
}
}
/* Reusable components */
@layer components {
.btn {
@apply inline-block px-4 py-2 rounded-md font-medium shadow-sm transition;
}
.btn-primary {
@apply btn bg-blue-600 text-white hover:bg-blue-700;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700;
}
.btn-outline {
@apply btn border border-gray-300 text-gray-800 hover:bg-gray-100;
}
.card {
@apply p-4 border border-gray-200 rounded-xl bg-white shadow-sm hover:shadow transition;
}
.section-title {
@apply text-2xl font-semibold mb-4 border-b pb-2;
}
.form-field {
@apply block w-full rounded-md border border-gray-300 shadow-sm mt-1;
}
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
}
/* Utility overrides or extras */
@layer utilities {
.text-muted {
@apply text-gray-500;
}
.max-w-read {
@apply max-w-prose mx-auto;
}
}

View File

@ -1,27 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
plugins: [require('@tailwindcss/typography')],
content: ["./src/app/views/**/*.{zmpl,html,js}", "./src/main.zig"],
theme: {
extend: {
colors: {
win7: {
bg: '#eaf3ff', // Background base
border: '#b5cde4', // Borders (like nav/footer lines)
text: '#003c75', // Base text (e.g., headings)
link: '#004f9f', // Link color
red: '#cc0000', // Accent red (e.g., for "Files")
hover: '#005dc1', // Link hover
},
warnbox: {
bg: '#fff8d8', // Background of JS-free box
text: '#7a5b00', // Text for warning
border: '#f5e8a3', // Border for warning
},
},
fontFamily: {
sans: ['"Segoe UI"', 'Tahoma', 'Geneva', 'Verdana', 'sans-serif'],
},
},
}
};