little nice update

This commit is contained in:
yuzu 2025-05-06 21:11:52 -05:00
parent 281043f96f
commit fa11ca9575
25 changed files with 4504 additions and 254 deletions

View File

@ -1,3 +1,2 @@
.zig-cache/ .zig-cache/
zig-out/ zig-out/

11
.gitignore vendored
View File

@ -4,4 +4,15 @@ data/
*.core *.core
.jetzig .jetzig
.zig-cache/ .zig-cache/
# local should be custom
Dockerfile
compose.yml
node_modules/
static/
jetzig_downloads.json
.bundle
bundle.tar.gz
public/styles.css
log/
.env.*
.env .env

View File

@ -1,6 +1,6 @@
FROM alpine:latest AS build FROM alpine:latest AS build
RUN apk add jq curl tar xz git vim RUN apk add jq curl tar xz git vim
RUN curl --output /zig.tar.xz "$(curl -s 'https://ziglang.org/download/index.json' | jq -r '.master."aarch64-linux".tarball')" RUN curl --output /zig.tar.xz "$(curl -s 'https://ziglang.org/download/index.json' | jq -r '.master."86_64-linux".tarball')"
RUN mkdir /zig RUN mkdir /zig
WORKDIR /zig WORKDIR /zig

2
build.sh Normal file
View File

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

View File

@ -9,8 +9,7 @@ pub fn build(b: *std.Build) !void {
.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 = true // fix for my arm machine
}); });
const options = b.addOptions(); const options = b.addOptions();

View File

@ -1,16 +1,16 @@
services: services:
server: # server:
env_file: ".env" # env_file: ".env"
build: # build:
context: . # context: .
dockerfile: Dockerfile # dockerfile: Dockerfile
ports: # ports:
- "8080:8080" # - "8080:8080"
restart: unless-stopped # restart: unless-stopped
depends_on: # depends_on:
- postgres # - postgres
networks: # networks:
- postgres-network # - postgres-network
postgres: postgres:
image: postgres:16 image: postgres:16
@ -19,13 +19,13 @@ services:
POSTGRES_USER: ${JETQUERY_USERNAME} POSTGRES_USER: ${JETQUERY_USERNAME}
POSTGRES_PASSWORD: ${JETQUERY_PASSWORD} POSTGRES_PASSWORD: ${JETQUERY_PASSWORD}
ports: ports:
- "5431:5432" - "5432:5432"
volumes: volumes:
- ./data/:/var/lib/postgresql/data/ - ./data/:/var/lib/postgresql/data/
networks: # networks:
- postgres-network # - postgres-network
networks: # networks:
postgres-network: # postgres-network:
driver: bridge # driver: bridge

View File

@ -1,6 +1,7 @@
pub const database = .{ pub const database = .{
.development = .{ .development = .{
.adapter = .postgresql, .adapter = .postgresql,
.hostname = "localhost",
}, },
.testing = .{ .testing = .{
.adapter = .postgresql, .adapter = .postgresql,

2409
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"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"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,8 @@ const Zmd = @import("zmd").Zmd;
const tokens = @import("zmd").tokens; const tokens = @import("zmd").tokens;
const Node = @import("zmd").Node; const Node = @import("zmd").Node;
pub const layout = "panel";
/// Default fragments. Pass this to `Zmd.toHtml` or provide your own. /// Default fragments. Pass this to `Zmd.toHtml` or provide your own.
/// Formatters can be functions receiving an allocator, the current node, and the rendered /// 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. /// content, or a 2-element tuple containing the open and close for each node.
@ -123,11 +125,35 @@ pub fn get(id: []const u8, request: *jetzig.Request) !jetzig.View {
var root = try request.data(.object); var root = try request.data(.object);
try root.put("blog", eb); try root.put("blog", eb);
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);
try request.process(); try request.process();
return request.render(.ok); return request.render(.ok);
} }
pub fn new(request: *jetzig.Request) !jetzig.View { pub fn new(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
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;
};
const root = try data.object();
try root.put("allowed", allowed);
return request.render(.ok); return request.render(.ok);
} }
@ -170,6 +196,31 @@ pub fn post(request: *jetzig.Request) !jetzig.View {
} }
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);
};
// check if session is valid
const query = jetzig.database.Query(.Session)
.findBy(.{ .session_id = session.value });
if (try request.repo.execute(query)) |_| {
const delete_query = jetzig.database.Query(.Blog)
.find(try std.fmt.parseInt(i32, id, 10));
if (try request.repo.execute(delete_query)) |blog_post| {
try request.repo.delete(blog_post);
}
return request.redirect("/blogs", .moved_permanently);
} else {
return request.fail(.not_found);
}
}
test "index" { test "index" {
var app = try jetzig.testing.app(std.testing.allocator, @import("routes")); var app = try jetzig.testing.app(std.testing.allocator, @import("routes"));

View File

@ -1,6 +1,6 @@
<div> <div>
<h1>{{.blog.title}}</h1> <h1>{{.blog.title}}</h1>
<div class="mt-6 prose prose-lg dark:prose-dark"> <div class="prose prose-slate max-w-none mt-6 dark:prose-dark">
{{zmpl.fmt.raw(zmpl.ref("blog.content"))}} {{zmpl.fmt.raw(zmpl.ref("blog.content"))}}
</div> </div>
</div> </div>

View File

@ -1,31 +1,54 @@
@args allowed: bool @args allowed: bool
<div> <div class="max-w-3xl mx-auto mt-10 space-y-6">
@for (.blogs) |blog| { @for (.blogs) |blog| {
<div class="p-4 border border-gray-200 rounded-xl shadow-sm hover:shadow transition">
<div class="flex justify-between items-center">
<a href="/blogs/{{blog.id}}" class="text-xl font-semibold text-blue-600 hover:underline">
{{blog.title}}
</a>
@if ($.allowed) @if ($.allowed)
<button id="delete-post"> <button
Delete post id="delete-post-{{blog.id}}"
class="text-sm text-red-600 hover:text-red-800"
>
Delete
</button> </button>
<script> @end
document.getElementById('delete-post').addEventListener('click', function () { </div>
fetch(`/blogs/${blog.id}`, {
<p class="text-gray-500 text-sm mt-1">
{{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-blue-600 text-white rounded-md hover:bg-blue-700 transition">
+ 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', method: 'DELETE',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
}) })
.catch(error => { .then(() => window.location.reload())
console.error('Error during logout:', error); .catch(err => console.error("Failed to delete post:", err));
}); });
} });
</script> });
@end </script>
<a href="/blogs/{{blog.id}}">{{blog.title}}</a> @end
{{zmpl.fmt.datetime(blog.get("created_at"), "%Y-%m-%d %H:%M")}}
<br/>
}
<hr/>
@if ($.allowed)
<a href="/blogs/new">New Blog</a>
@end
</div>

View File

@ -1,12 +1,25 @@
<div> <div class="max-w-2xl mx-auto mt-10 p-6 bg-white shadow-md rounded-2xl">
<form action="/blogs" method="POST"> <form action="/blogs" method="POST" class="space-y-6">
{{context.authenticityFormElement()}} {{context.authenticityFormElement()}}
<label>Title</label>
<input name="title" /> <div>
<label>Content</label> <label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<textarea name="content"></textarea> <input name="title" id="title" class="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" />
<label>Preview</label> </div>
<textarea name="preview"></textarea>
<input type="submit" /> <div>
<label for="content" class="block text-sm font-medium text-gray-700">Content</label>
<textarea name="content" id="content" rows="8" class="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50"></textarea>
</div>
<div>
<label for="preview" class="block text-sm font-medium text-gray-700">Preview</label>
<textarea name="preview" id="preview" rows="3" class="mt-1 w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50"></textarea>
</div>
<div>
<input type="submit" value="Publish" class="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md shadow hover:bg-blue-700 transition-colors" />
</div>
</form> </form>
</div> </div>

View File

@ -0,0 +1,30 @@
@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" },
};
}
<footer class="mt-12 border-t pt-6 text-center text-sm text-gray-600">
<div class="max-w-xl mx-auto px-4">
<div class="bg-yellow-100 text-yellow-800 border border-yellow-200 rounded-md px-4 py-3 mb-6">
<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-blue-500">
@zig {
inline for (links) |link| {
<a href="{{link.href}}" class="hover:underline">{{link.title}}</a>
}
}
</div>
<p class="text-xs text-gray-500 padding-bottom-5">&copy; 2025 yuzucchii.xyz</p>
</div>
</footer>

View File

@ -0,0 +1,34 @@
@args allowed: bool
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/styles.css" />
<title>yuzucchii.xyz</title>
</head>
<body>
<main>
<div class="mx-auto max-w-3xl mt-sm">
<br/>
<h1 class="text-3xl font-bold mb-4">Hello, I like doing things.</h1>
<div class="mb-8">
<a href="/blogs" class="text-blue-500 hover:underline">Blog</a>
@if ($.allowed)
<a href="/logout" class="text-blue-500 hover:underline ml-4">Logout</a>
@else
<a href="/login" class="text-blue-500 hover:underline ml-4">Login</a>
@end
<a href="https://git.yuzucchii.xyz" class="text-blue-500 hover:underline ml-4" rel="noopener">Gitea</a>
<a href="/rss" class="text-blue-500 hover:underline ml-4">RSS feed</a>
<a href="https://searx.yuzucchii.xyz" class="text-blue-500 hover:underline ml-4">SearX</a>
<a href="/files" class="text-red-500 hover:underline ml-4">Files</a>
<a href="mailto:me@yuzucchii.xyz" class="text-blue-500 hover:underline ml-4">E-mail</a>
</div>
{{zmpl.content}}
</main>
</body>
@partial layouts/footer
</html>

View File

@ -2,9 +2,21 @@ const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
const uuid4 = @import("uuid").v4; const uuid4 = @import("uuid").v4;
pub fn index(request: *jetzig.Request) !jetzig.View { pub const layout = "panel";
// check if logged in
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
const cookies = try request.cookies(); 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;
};
const root = try data.object();
try root.put("allowed", allowed);
if (cookies.get("session")) |session| if (session.value.len != 0) if (cookies.get("session")) |session| if (session.value.len != 0)
return request.redirect("/blogs", .moved_permanently); return request.redirect("/blogs", .moved_permanently);
@ -43,9 +55,11 @@ pub fn post(request: *jetzig.Request) !jetzig.View {
.value = uuid, .value = uuid,
.path = "/", .path = "/",
.domain = @import("dev").DOMAIN, .domain = @import("dev").DOMAIN,
.same_site = .lax,
.http_only = true, .http_only = true,
.secure = false, .secure = false,
.max_age = 60 * 60 * 24 * 7, // 1 week .max_age = 60 * 60 * 24 * 7, // 1 week
.partitioned = false,
}); });
// post to Session table // post to Session table

View File

@ -1,9 +1,25 @@
<div> <title>Login panel for admins</title>
<form id="login-form"> <body>
<label>Discrete password</label> <div class="min-h-screen flex items-center justify-center bg-gray-100">
<input name="password" type="password"/> <form id="login-form" class="bg-white p-8 rounded-2xl shadow-xl w-full max-w-sm space-y-6">
<button type="submit">Login</button> <div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Discrete password</label>
<input
id="password"
name="password"
type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition"
/>
</div>
<button
type="submit"
class="w-full bg-black text-white font-semibold py-2 rounded-md hover:bg-gray-800 transition-colors"
>
Login
</button>
</form> </form>
</div>
<script> <script>
document.querySelector('#login-form').addEventListener('submit', function(event) { document.querySelector('#login-form').addEventListener('submit', function(event) {
@ -26,5 +42,4 @@
}); });
}); });
</script> </script>
</div> </body>

View File

@ -1,7 +1,21 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request) !jetzig.View { pub const layout = "panel";
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
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;
};
const root = try data.object();
try root.put("allowed", allowed);
return request.render(.ok); return request.render(.ok);
} }

View File

@ -1,7 +1,18 @@
<button id="logout-button">Logout</button>
<script> <title>Login panel for admins</title>
document.getElementById('logout-button').addEventListener('click', function () { <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', { fetch('/logout', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -22,7 +33,7 @@ document.getElementById('logout-button').addEventListener('click', function () {
.catch(error => { .catch(error => {
console.error('Error during logout:', error); console.error('Error during logout:', error);
}); });
}); });
</script>
</script>
</body>

View File

@ -1,50 +1,17 @@
const std = @import("std"); const std = @import("std");
const jetzig = @import("jetzig"); const jetzig = @import("jetzig");
pub const layout = "panel";
const Article = struct { const Article = struct {
title: [255]u8, title: [255]u8,
blob: [255]u8 blob: [255]u8
}; };
/// `src/app/views/root.zig` represents the root URL `/`
/// The `index` view function is invoked when when the HTTP verb is `GET`.
/// Other view types are invoked either by passing a resource ID value (e.g. `/1234`) or by using
/// a different HTTP verb:
///
/// GET / => index(request, data)
/// GET /1234 => get(id, request, data)
/// POST / => post(request, data)
/// PUT /1234 => put(id, request, data)
/// PATCH /1234 => patch(id, request, data)
/// DELETE /1234 => delete(id, request, data)
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View { pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
// The first call to `data.object()` or `data.array()` sets the root response data value.
// JSON requests return a JSON string representation of the root data value.
// Zmpl templates can access all values in the root data value.
var root = try data.object(); var root = try data.object();
// Add a string to the root object.
try root.put("title", data.string("yuzucchii.xyz"));
// Request params have the same type as a `data.object()` so they can be inserted them
// directly into the response data. Fetch `http://localhost:8080/?message=hello` to set the
// param. JSON data is also accepted when the `content-type: application/json` header is
// present.
const params = try request.params();
const cookies = try request.cookies(); const cookies = try request.cookies();
var obj = try data.object();
try obj.put("title",data.string("The simplest article ever"));
try obj.put("description",data.string("Hello in this article I being to do things"));
var objj = try data.object();
try objj.put("title",data.string("The most complicated thing ever"));
try objj.put("description",data.string("Dude, that shit is like harder than a rock"));
var array = try root.put("articles", .array);
try array.append(obj);
try array.append(objj);
const query = jetzig.database.Query(.Blog).orderBy(.{.created_at = .desc}); const query = jetzig.database.Query(.Blog).orderBy(.{.created_at = .desc});
const blogs = try request.repo.all(query); const blogs = try request.repo.all(query);
@ -61,12 +28,6 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
try root.put("allowed", allowed); try root.put("allowed", allowed);
try root.put("bsky_link", "empty for now");
try root.put("discord_link", "https://discord.gg/pvraBkepUP");
try root.put("codeberg_link", "https://codeberg.org/yuzu");
try root.put("message_param", params.get("message"));
// Set arbitrary response headers as required. `content-type` is automatically assigned for // Set arbitrary response headers as required. `content-type` is automatically assigned for
// HTML, JSON responses. // HTML, JSON responses.
// //

View File

@ -1,90 +1,24 @@
@args articles: Zmpl.Array, allowed: bool @args articles: Zmpl.Array
<section class="max-w-3xl mx-auto px-4 py-8">
<html> <!-- Banner -->
<head>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/styles.css" />
<title> {{.title}} </title
</head>
<body>
<div class="mx-auto max-w-3xl mt-sm">
<br/>
<h1 class="text-3xl font-bold mb-4">Hello, I like doing things.</h1>
<div class="mb-8"> <div class="mb-8">
<a href="/blogs" class="text-blue-500 hover:underline">Blog</a>
@if ($.allowed)
<a href="/logout" class="text-blue-500 hover:underline ml-4">Logout</a>
@else
<a href="/login" class="text-blue-500 hover:underline ml-4">Login</a>
@end
<a href="https://git.yuzucchii.xyz" class="text-blue-500 hover:underline ml-4" rel="noopener">Gitea instance</a>
<a href="https://chat.yuzucchii.xyz" class="text-blue-500 hover:underline ml-4" rel="noopener">IRC</a>
<a href="/rss" class="text-blue-500 hover:underline ml-4">RSS feed</a>
<a href="https://searx.yuzucchii.xyz" class="text-red-500 hover:underline ml-4">SearX</a>
<a href="/files" class="text-red-500 hover:underline ml-4">Files</a>
<a href="mailto:me@yuzucchii.xyz" class="text-blue-500 hover:underline ml-4">E-mail</a>
<button
id="copy-button"
type="button"
onclick="navigator.clipboard.writeText('mc.yuzucchii.xyz'); showCopiedMessage();"
class="js-only text-green-500 hover:underline ml-4 bg-transparent p-0 border-none cursor-pointer"
>
Minecraft server
</button>
<span id="copy-msg" class="js-only hidden text-green-600 ml-2 hidden">Copied!</span>
<script>
const msg = document.getElementById('copy-msg');
msg.classList.remove('js-only');
const button = document.getElementById('copy-button');
button.classList.remove('js-only')
</script>
<noscript>
<p class="text-red-500 ml-4">Minecraft server: mc.yuzucchii.xyz</p>
<hr/>
<p class="text-red-500 ml-4">This site works without JavaScript enabled, enjoy your experience</p>
</noscript>
</div>
<!-- Banner Image -->
<div class="mb-6">
<img src="/banner.jpg" alt="Banner" class="w-full rounded-xl shadow-md"> <img src="/banner.jpg" alt="Banner" class="w-full rounded-xl shadow-md">
</div> </div>
<p class="text-lg mb-6">I created this website in order to store things I do, but don't be scared of the simple layout, this website is featureful and full of things to do, please enjoy your visit.</p>
@for (.articles) |article| { <!-- Introduction -->
@partial root/article_blob(title: article.title, blob: article.content, id: article.id) <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 —
</div> this website is featureful and full of things to explore. Please enjoy your visit.
<footer class="mt-12 border-t pt-6 text-center text-sm text-gray-600">
<div class="max-w-xl mx-auto px-4">
<div class="bg-yellow-100 text-yellow-800 border border-yellow-200 rounded-md px-4 py-3 mb-6">
<p class="text-sm md:text-base">
This website is not reliant on JavaScript, and all scripts are optional and publicly available.
</p> </p>
</div>
<div class="flex justify-center flex-wrap gap-4 mb-4 text-blue-500"> <!-- Articles Section -->
<a href="{{.bsky_link}}" class="hover:underline" target="_blank" rel="noopener">Bluesky</a> <h2 class="text-2xl font-semibold mb-4 border-b pb-2">Latest Articles</h2>
<a href="{{.discord_link}}" class="hover:underline" target="_blank" rel="noopener">Discord</a>
<a href="{{.codeberg_link}}" class="hover:underline" target="_blank" rel="noopener">Codeberg</a>
<a href="https://yuzucchii.xyz" class="hover:underline" target="_blank" rel="noopener">yuzucchii.xyz</a>
</div>
<p class="text-xs text-gray-500">&copy; 2025 yuzucchii.xyz</p> <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>
</footer>
</body>
<script>
function showCopiedMessage() {
const msg = document.getElementById('copy-msg');
msg.classList.remove('hidden');
setTimeout(() => msg.classList.add('hidden'), 2000);
} }
</script> </div>
</html> </section>

View File

@ -13,6 +13,7 @@ pub const jetzig_options = struct {
/// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`). /// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`).
pub const middleware: []const type = &.{ pub const middleware: []const type = &.{
jetzig.middleware.AntiCsrfMiddleware, jetzig.middleware.AntiCsrfMiddleware,
//@import("app/middleware/BasicMiddleware.zig"),
//@import("app/middleware/AuthMiddleware.zig"), //@import("app/middleware/AuthMiddleware.zig"),
}; };

73
src/tailwind.css Normal file
View File

@ -0,0 +1,73 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base typography improvements */
@layer base {
body {
@apply bg-white text-gray-800 antialiased leading-relaxed;
}
h1, h2, h3, h4, h5, h6 {
@apply font-semibold text-gray-900;
}
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;
}
}

22
tailwind.config.js Normal file
View File

@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/app/views/**/*.{zmpl,html,js}", "./src/main.zig"],
theme: {
extend: {
typography: ({ theme }) => ({
DEFAULT: {
css: {
color: theme('colors.gray.800'),
a: { color: theme('colors.blue.600') },
pre: { backgroundColor: theme('colors.gray.100') },
blockquote: {
borderLeftColor: theme('colors.yellow.400'),
fontStyle: 'italic',
},
}
}
})
}
},
plugins: [require('@tailwindcss/typography')],
}