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

View File

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

View File

@ -2,12 +2,10 @@ pub const database = .{
.development = .{ .development = .{
.adapter = .postgresql, .adapter = .postgresql,
.hostname = "localhost", .hostname = "localhost",
.port = 5432,
}, },
.testing = .{ .testing = .{
.adapter = .postgresql, .adapter = .postgresql,
}, },
.production = .{ .production = .{ .adapter = .postgresql, .hostname = "postgres" },
.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 { struct {
id: i32, id: i32,
title: []const u8, title: []const u8,
blob: []const u8,
content: ?[]const u8, content: ?[]const u8,
blob: []const u8,
created_at: jetquery.DateTime, created_at: jetquery.DateTime,
updated_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"> <div>
<h2 class="text-3xl font-bold mb-6 pb-2 border-b border-win7-border"> <h2>About Me</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. 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>
<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. 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>
<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. 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>
<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. 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> </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> </div>

View File

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
const jetquery = jetzig.jetquery;
const Zmd = @import("zmd").Zmd; const Zmd = @import("zmd").Zmd;
const tokens = @import("zmd").tokens; const tokens = @import("zmd").tokens;
@ -7,75 +8,102 @@ const Node = @import("zmd").Node;
pub const layout = "panel"; pub const layout = "panel";
/// Default fragments. Pass this to `Zmd.toHtml` or provide your own. pub const Tag = struct {
/// Formatters can be functions receiving an allocator, the current node, and the rendered id: i32,
/// content, or a 2-element tuple containing the open and close for each node. name: []const u8,
pub const Fragments = struct { emoji: ?[]const u8,
pub fn root(allocator: std.mem.Allocator, node: Node) ![]const u8 { created_at: jetzig.jetquery.DateTime,
return try std.fmt.allocPrint(allocator, "{s}", .{node.content}); updated_at: jetzig.jetquery.DateTime,
}
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 = .{ "", "" };
}; };
/// Escape HTML entities. pub const Blog = struct {
pub fn escape(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { id: i32,
const replacements = .{ title: []const u8,
.{ "&", "&amp;" }, content: ?[]const u8,
.{ "<", "&lt;" }, blob: []const u8,
.{ ">", "&gt;" }, created_at: jetzig.jetquery.DateTime,
}; updated_at: jetzig.jetquery.DateTime,
tags: ?[]const Tag,
};
var output = input; pub fn fetchBlogs(allocator: std.mem.Allocator, req: *jetzig.Request) ![]Blog {
inline for (replacements) |replacement| { const tag_query = jetzig.database.Query(.Tag)
output = try std.mem.replaceOwned(u8, allocator, output, replacement[0], replacement[1]); .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 { 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); var root = try request.data(.object);
const blogs = try fetchBlogs(request.allocator, request);
try root.put("blogs", blogs); try root.put("blogs", blogs);
const cookies = try request.cookies(); 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 { 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); var zmd = Zmd.init(request.allocator);
defer zmd.deinit(); defer zmd.deinit();
var blog = try fetchBlog(
request.allocator,
request,
try std.fmt.parseInt(i32, id, 10),
);
try zmd.parse(blog.content orelse { try zmd.parse(blog.content orelse {
return request.fail(.unprocessable_entity); return request.fail(.unprocessable_entity);
}); });
const blob = try zmd.toHtml(Fragments); const blob = try zmd.toHtml(@import("zmd").html.DefaultFragments);
const eb = Blog{ blog.content = blob;
.id = blog.id,
.title = blog.title,
.content = blob,
.created_at = blog.created_at,
.updated_at = blog.updated_at,
};
var root = try request.data(.object); var root = try request.data(.object);
try root.put("blog", eb); try root.put("blog", blog);
const cookies = try request.cookies(); const cookies = try request.cookies();
const allowed = blk: { const allowed = blk: {
@ -184,17 +201,51 @@ pub fn post(request: *jetzig.Request) !jetzig.View {
return request.fail(.unprocessable_entity); return request.fail(.unprocessable_entity);
}; };
const tag_str = params.getT(.string, "tags") orelse {
return request.fail(.unprocessable_entity);
};
try request.repo.insert(.Blog, .{ try request.repo.insert(.Blog, .{
.title = title, .title = title,
.blob = preview, .blob = preview,
.content = content, .content = content,
}); });
return request.redirect("/blogs", .moved_permanently); 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 { } else {
return request.fail(.not_found); return request.fail(.not_found);
} }
},
});
}
return request.redirect("/blogs", .moved_permanently);
}
// session is not valid
return request.fail(.not_found);
} }
pub fn delete(id: []const u8, request: *jetzig.Request) !jetzig.View { 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) const blog_query = jetzig.database.Query(.Blog)
.find(try std.fmt.parseInt(i32, id, 10)); .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); return request.fail(.not_found);
}; };
const root = try request.data(.object); const root = try request.data(.object);
try root.put("allowed", true); 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); try root.put("blog", blog);
return request.render(.ok); 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 title = params.getT(.string, "title") orelse blog.title;
const content = params.getT(.string, "content") orelse blog.content; const content = params.getT(.string, "content") orelse blog.content;
const preview = params.getT(.string, "preview") orelse blog.blob; const preview = params.getT(.string, "preview") orelse blog.blob;
const tags = params.getT(.string, "tags") orelse "";
// query the blogpost first // query the blogpost first
const blog_query = jetzig.database.Query(.Blog) const blog_query = jetzig.database.Query(.Blog)
.update(.{ .title = title, .blob = preview, .content = content }) .update(.{ .title = title, .blob = preview, .content = content })
.where(.{ .id = try std.fmt.parseInt(i32, id, 10) }); .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 // update the blogpost
try request.repo.execute(blog_query); 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"> <div>
<form action="/blogs/{{.blog.id}}/edit" method="POST" class="space-y-6"> <form action="/blogs/{{.blog.id}}/edit" method="POST">
<div> <div>
<label for="title" class="block text-sm font-medium mb-1">Title</label> <label for="title">Title</label>
<input <input
type="text" type="text"
name="title" name="title"
id="title" id="title"
value="{{.blog.title}}" value="{{.blog.title}}"
required 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>
<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 <textarea
name="content" name="content"
id="content" id="content"
rows="8" rows="8"
required 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> >{{.blog.content}}</textarea>
</div> </div>
<div> <div>
<label for="preview" class="block text-sm font-medium mb-1">Preview</label> <label for="preview">Preview</label>
<textarea <textarea
name="preview" name="preview"
id="preview" id="preview"
rows="3" 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> >{{.blog.blob}}</textarea>
</div> </div>
@ -38,7 +46,6 @@
<input <input
type="submit" type="submit"
value="Save Changes" value="Save Changes"
class="px-4 py-2 bg-win7-link text-white font-semibold rounded-md shadow hover:bg-win7-hover transition"
/> />
</div> </div>
</form> </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"> <div>
<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> <h1>{{.blog.title}}</h1>
<article class="prose lg:prose-xl"> <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"))}} {{zmpl.fmt.raw(zmpl.ref("blog.content"))}}
</article> </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>
</div> </div>

View File

@ -1,43 +1,26 @@
@args allowed: bool <div>
@for ($.blogs) |blog| {
<div class="max-w-3xl mx-auto mt-10 space-y-6 font-sans"> <div>
@for (.blogs) |blog| { <a href="/blogs/{{blog.id}}">
<div class="p4 bg-win7-bg border border-win7-border rounded-xl px-5 py-4 shadow-sm hover:shadow-md transition duration-150"> {{zmpl.fmt.datetime(blog.get("created_at"), "%Y-%m-%d | ")}} {{blog.title}}
<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> </a>
<button @if ($.allowed)
id="delete-post-{{blog.id}}" <div>
class="text-sm text-win7-red hover:text-red-800 transition" <a href="/blogs/{{blog.id}}/edit">Edit</a>
> <button id="delete-post-{{blog.id}}">Delete</button>
Delete
</button>
</div> </div>
@end @end
</div> </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) @if ($.allowed)
<div class="pt-4"> <br>
<a href="/blogs/new" <div><a href="/blogs/new">+ New Blog</a></div>
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>
@end @end
</div> </div>
@if ($.allowed) @if ($.allowed)
<script> <script>
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[id^="delete-post-"]').forEach(button => { document.querySelectorAll('[id^="delete-post-"]').forEach(button => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
@ -55,4 +38,3 @@
}); });
</script> </script>
@end @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"> <div>
<form action="/blogs" method="POST" class="space-y-6"> <form action="/blogs" method="POST">
<div> <div>
<label for="title" class="block text-sm font-medium mb-1">Title</label> <label for="title">Title</label>
<input <input
name="title" name="title"
id="title" id="title"
required 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>
<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 <textarea
name="content" name="content"
id="content" id="content"
rows="8" rows="8"
required 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> ></textarea>
</div> </div>
<div> <div>
<label for="preview" class="block text-sm font-medium mb-1">Preview</label> <label for="preview">Preview</label>
<textarea <textarea
name="preview" name="preview"
id="preview" id="preview"
rows="3" 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> ></textarea>
</div> </div>
@ -36,7 +43,6 @@
<input <input
type="submit" type="submit"
value="Publish" value="Publish"
class="px-4 py-2 bg-win7-link text-white font-semibold rounded-md shadow hover:bg-win7-hover transition"
/> />
</div> </div>
</form> </form>

View File

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

View File

@ -3,28 +3,19 @@
.{ .href = "https://yuzucchii.xyz", .title = "Home" }, .{ .href = "https://yuzucchii.xyz", .title = "Home" },
.{ .href = "/rss", .title = "RSS" }, .{ .href = "/rss", .title = "RSS" },
.{ .href = "https://codeberg.org/yuzu", .title = "Codeberg" }, .{ .href = "https://codeberg.org/yuzu", .title = "Codeberg" },
.{ .href = "https://bsky.app/", .title = "Bluesky" }, .{ .href = "https://bsky.app/profile/yuzucchii.bsky.social", .title = "Bluesky" },
.{ .href = "https://discord.gg/pvraBkepUP", .title = "Discord" }, .{ .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"> <footer>
<div class="max-w-xl mx-auto px-4"> <img src="/cc.png" alt="CC from Code Geass" width="100px" height="100px"/>
<p>
<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 { @zig {
inline for (links) |link| { inline for (links) |link| {
<a href="{{link.href}}" class="hover:underline">{{link.title}}</a> <a href="{{link.href}}">{{link.title}}</a>
} }
} }
</div> &copy; 2025 yuzucchii.xyz
</p>
<p class="text-xs text-[#4a5c75] pb-5">&copy; 2025 yuzucchii.xyz</p>
</div>
</footer> </footer>

View File

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

@ -4,39 +4,31 @@
<head> <head>
@partial layouts/metadata @partial layouts/metadata
<link rel="icon" href="/favicon.ico" /> <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> <title>yuzucchii.xyz</title>
</head> </head>
<body> <body>
<nav class="bg-win7-bg shadow-sm border-b border-win7-border py-4 font-sans"> <div class="container">
<div class="max-w-4xl mx-auto text-center"> <nav>
<div class="text-2xl font-bold text-win7-text mb-2"> <div>
<a href="/" class="hover:text-win7-hover transition">yuzucchii.xyz</a> <a href="/">Home</a>
</div> <a href="/blogs">Blog</a>
<div class="flex flex-wrap justify-center gap-4 text-sm sm:text-base text-win7-link"> <a href="/about-me">About Me</a>
<a href="/blogs" class="hover:underline">Blog</a>
<a href="/about-me" class="hover:underline">About Me</a>
@if ($.allowed) @if ($.allowed)
<a href="/logout" class="hover:underline">Logout</a> <a href="/logout">Logout</a>
@else
<a href="/login" class="hover:underline">Login</a>
@end @end
<a href="https://git.yuzucchii.xyz" class="hover:underline" rel="noopener">Gitea</a> <a href="https://git.yuzucchii.xyz">Gitea</a>
<a href="https://watcharr.yuzucchii.xyz" class="hover:underline">Watcharr</a> <a href="/rss">RSS</a>
<a href="/rss" class="hover:underline">RSS</a> <a href="mailto:me@yuzucchii.xyz">E-mail</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> </div>
</nav> </nav>
<!-- Page Content --> <!-- Page Content -->
<main class="max-w-3xl mx-auto px-4 mt-8"> <main>
<h1 class="text-3xl font-bold mb-6">Hello, I like doing things.</h1>
{{zmpl.content}} {{zmpl.content}}
</main> </main>
</div>
@partial layouts/footer @partial layouts/footer
</body> </body>

View File

@ -1,35 +1,28 @@
<div class="min-h-screen flex items-center justify-center bg-win7-bg font-sans text-win7-text"> <div>
<form id="login-form">
<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>Admin Login</h1>
<h1 class="text-xl font-semibold text-center mb-2 text-win7-link">Admin Login</h1>
<div> <div>
<label for="username" class="block text-sm font-medium mb-1">Username</label> <label for="username">Username</label>
<input <input
id="username" id="username"
name="username" name="username"
type="text" type="text"
required 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>
<div> <div>
<label for="password" class="block text-sm font-medium mb-1">Discrete password</label> <label for="password">Discrete password</label>
<input <input
id="password" id="password"
name="password" name="password"
type="password" type="password"
required 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>
<button <button type="submit">
type="submit"
class="w-full bg-win7-link text-white font-semibold py-2 rounded-md hover:bg-win7-hover shadow transition"
>
Login Login
</button> </button>
</form> </form>

View File

@ -1,18 +1,9 @@
<div>
<button id="logout-button">Logout</button>
</div>
<title>Login panel for admins</title> <script>
<body> document.getElementById('logout-button').addEventListener('click', function () {
<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', { fetch('/logout', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -28,12 +19,10 @@
}) })
.then(data => { .then(data => {
console.log('Logout successful:', data); console.log('Logout successful:', data);
// window.location.href = '/login'; // redirect if needed window.location.href = '/login';
}) })
.catch(error => { .catch(error => {
console.error('Error during logout:', error); console.error('Error during logout:', error);
}); });
}); });
</script>
</script>
</body>

View File

@ -1,20 +1,22 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
pub const layout = "panel"; pub const layout = "minimal";
const Article = struct { const Article = struct { title: [255]u8, blob: [255]u8 };
title: [255]u8,
blob: [255]u8
};
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var root = try data.object(); var root = try data.object();
const cookies = try request.cookies(); 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); const blogs = try request.repo.all(query);
try root.put("tags", tags);
try root.put("articles", blogs); try root.put("articles", blogs);
const allowed = blk: { const allowed = blk: {

View File

@ -1,8 +1,8 @@
@args title: []const u8, id: i32, blob: []const u8 @args title: []const u8, id: i32, blob: []const u8
<div class="mb-4"> <div>
<h2 class="text-2xl font-semibold mb-2">{{title}}</h2> <a href="/blogs/{{id}}">{{title}}</a>
<div class="text-gray-700"> <div>
{{blob}} {{blob}}
</div> </div>
<a href="/blogs/{{id}}" class="text-blue-600 underline">Read more</a> <br>
</div> </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 @args articles: Zmpl.Array
<section class="max-w-3xl mx-auto px-4 py-8"> <section>
<!-- Banner --> <!-- Links Section -->
<div class="mb-8"> <ul class="main-menu">
<img src="/new-banner.webp" alt="Banner" class="w-full rounded-xl shadow-md"> <li class="main-menu-item">
</div> <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 --> <!-- Introduction -->
<p class="text-lg leading-relaxed mb-10 text-gray-700"> <p>
I created this website in order to store things I do. Dont be scared of the simple layout — 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. this website is featureful and full of things to explore. Please enjoy your visit.
</p> </p>
<!-- Articles Section --> <!-- Articles Section -->
<h2 class="text-2xl font-semibold mb-4 border-b pb-2">Latest Articles</h2> <h2>Latest Articles</h2>
<div class="space-y-6"> <div>
@for (.articles) |article| {
<div class="p-4 border rounded-xl bg-white shadow-sm hover:shadow transition"> @for (.tags) |tag| {
@partial root/article_blob(title: article.title, blob: article.blob, id: article.id) @if ($.tag.emoji) |emoji|
<div>
@partial root/tag_blob(name: tag.name, emoji: emoji)
</div> </div>
} @else
<div>
@partial root/tag_blob(name: tag.name, emoji: "")
</div> </div>
@end
}
@for (.articles) |article| {
@partial root/article_blob(title: article.title, blob: article.blob, id: article.id)
}
</div>
</section> </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 { pub fn init(app: *jetzig.App) !void {
app.route(.GET, "/rss.xml", @import("app/views/rss.zig"), .index); 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(.GET, "/blogs/:id/edit", @import("app/views/blogs.zig"), .edit);
app.route(.POST, "/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 { 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'],
},
},
}
};