Compare commits
8 Commits
9fff6fbf7f
...
eaccfada73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaccfada73 | ||
|
|
059fdf1b49 | ||
|
|
622afc310d | ||
|
|
27887bf664 | ||
|
|
7176e01e8c | ||
|
|
0bfd2f8674 | ||
|
|
35f0e7af00 | ||
|
|
f236f655d1 |
5982
Cargo.lock
generated
Normal file
5982
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ sea-orm = { version = "1.1", features = [
|
||||
"macros",
|
||||
] }
|
||||
chrono = { version = "0.4" }
|
||||
time = { version = "0.3" }
|
||||
validator = { version = "0.20" }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
include_dir = { version = "0.7" }
|
||||
|
||||
2
assets/static/css/app.css
Normal file
2
assets/static/css/app.css
Normal file
File diff suppressed because one or more lines are too long
19
assets/views/admin/about.html
Normal file
19
assets/views/admin/about.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit About{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Edit About</h1>
|
||||
|
||||
<form method="post" action="/admin/about">
|
||||
<label>
|
||||
Title
|
||||
<input type="text" name="title" value="{{ page.title }}" required>
|
||||
</label>
|
||||
<label>
|
||||
Content
|
||||
<textarea name="content" rows="16" required>{{ page.content }}</textarea>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
114
assets/views/admin/base.html
Normal file
114
assets/views/admin/base.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Admin{% endblock title %}</title>
|
||||
<script>
|
||||
function applyTheme(t) {
|
||||
var dark = t === 'dark'
|
||||
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
}
|
||||
function highlightTheme(t) {
|
||||
document.querySelectorAll('[data-theme-opt]').forEach(function (b) {
|
||||
var on = b.getAttribute('data-theme-opt') === t;
|
||||
b.classList.toggle('active', on);
|
||||
var chk = b.querySelector('.opt-check');
|
||||
if (chk) chk.classList.toggle('hidden', !on);
|
||||
});
|
||||
}
|
||||
function setTheme(t) {
|
||||
localStorage.setItem('theme', t);
|
||||
applyTheme(t);
|
||||
highlightTheme(t);
|
||||
}
|
||||
applyTheme(localStorage.getItem('theme') || 'system');
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system');
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
highlightTheme(localStorage.getItem('theme') || 'system');
|
||||
});
|
||||
</script>
|
||||
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<style>
|
||||
.btn { --animation-btn: 0; --btn-focus-scale: 1; }
|
||||
#nav-backdrop { display: none; }
|
||||
@media (max-width: 767px) {
|
||||
#nav-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
|
||||
}
|
||||
.navbar:has(.dropdown:focus-within) ~ #nav-backdrop {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.15s ease, visibility 0s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased">
|
||||
<header class="navbar bg-base-100 shadow-sm">
|
||||
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
|
||||
<a href="/admin/dashboard" class="min-w-0 truncate text-lg font-bold">Admin</a>
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<a href="/admin/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>
|
||||
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm">Blog</a>
|
||||
<a href="/admin/about" class="btn btn-ghost btn-sm">About</a>
|
||||
<a href="/" class="btn btn-ghost btn-sm">View site</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="dropdown dropdown-end md:hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div tabindex="0"
|
||||
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||
<a href="/admin/dashboard" class="btn btn-ghost btn-sm justify-start">Dashboard</a>
|
||||
<a href="/admin/blog/articles" class="btn btn-ghost btn-sm justify-start">Blog</a>
|
||||
<a href="/admin/about" class="btn btn-ghost btn-sm justify-start">About</a>
|
||||
<a href="/" class="btn btn-ghost btn-sm justify-start">View site</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="btn btn-ghost btn-sm w-full justify-start">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Settings" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||
<li class="menu-title">Theme</li>
|
||||
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div id="nav-backdrop" aria-hidden="true"></div>
|
||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
31
assets/views/admin/blog/edit.html
Normal file
31
assets/views/admin/blog/edit.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Edit Article{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Edit Article</h1>
|
||||
|
||||
<form method="post" action="/admin/blog/articles/{{ article.id }}">
|
||||
<label>
|
||||
Title
|
||||
<input type="text" name="title" value="{{ article.title }}" required>
|
||||
</label>
|
||||
<label>
|
||||
Excerpt
|
||||
<textarea name="excerpt" rows="4">{% if article.excerpt %}{{ article.excerpt }}{% endif %}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Content
|
||||
<textarea name="content" rows="18" required>{{ article.content }}</textarea>
|
||||
</label>
|
||||
<label>
|
||||
Featured image id
|
||||
<input type="text" name="featured_image_id" value="{% if article.featured_image_id %}{{ article.featured_image_id }}{% endif %}">
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="published" {% if article.published %}checked{% endif %}>
|
||||
Published
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
36
assets/views/admin/blog/index.html
Normal file
36
assets/views/admin/blog/index.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Blog Articles{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Blog Articles</h1>
|
||||
<p><a href="/admin/blog/articles/new">New article</a></p>
|
||||
|
||||
{% if articles | length > 0 %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for article in articles %}
|
||||
<tr>
|
||||
<td>{{ article.title }}</td>
|
||||
<td>{% if article.published %}Published{% else %}Draft{% endif %}</td>
|
||||
<td>
|
||||
<a href="/admin/blog/articles/{{ article.id }}/edit">Edit</a>
|
||||
<form method="post" action="/admin/blog/articles/{{ article.id }}/delete">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No articles yet.</p>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
31
assets/views/admin/blog/new.html
Normal file
31
assets/views/admin/blog/new.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}New Article{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>New Article</h1>
|
||||
|
||||
<form method="post" action="/admin/blog/articles">
|
||||
<label>
|
||||
Title
|
||||
<input type="text" name="title" required>
|
||||
</label>
|
||||
<label>
|
||||
Excerpt
|
||||
<textarea name="excerpt" rows="4"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Content
|
||||
<textarea name="content" rows="18" required></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Featured image id
|
||||
<input type="text" name="featured_image_id">
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="published">
|
||||
Published
|
||||
</label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
13
assets/views/admin/index.html
Normal file
13
assets/views/admin/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Admin{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Admin</h1>
|
||||
<p>Logged in as {{ admin.email }}</p>
|
||||
|
||||
<ul>
|
||||
<li><a href="/admin/blog/articles">Manage blog articles</a></li>
|
||||
<li><a href="/admin/about">Edit about page</a></li>
|
||||
</ul>
|
||||
{% endblock content %}
|
||||
29
assets/views/admin/login.html
Normal file
29
assets/views/admin/login.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin login{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mx-auto mt-8 max-w-sm">
|
||||
<div class="card border border-base-300 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Admin login</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<span>Invalid email or password.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/login" class="space-y-2">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Email</span></label>
|
||||
<input type="email" name="email" required class="input input-bordered w-full">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Password</span></label>
|
||||
<input type="password" name="password" required class="input input-bordered w-full">
|
||||
</div>
|
||||
<button class="btn btn-neutral mt-2 w-full">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
108
assets/views/base.html
Normal file
108
assets/views/base.html
Normal file
@@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Universal Web{% endblock title %}</title>
|
||||
<script>
|
||||
function applyTheme(t) {
|
||||
var dark = t === 'dark'
|
||||
|| (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
}
|
||||
function highlightTheme(t) {
|
||||
document.querySelectorAll('[data-theme-opt]').forEach(function (b) {
|
||||
var on = b.getAttribute('data-theme-opt') === t;
|
||||
b.classList.toggle('active', on);
|
||||
var chk = b.querySelector('.opt-check');
|
||||
if (chk) chk.classList.toggle('hidden', !on);
|
||||
});
|
||||
}
|
||||
function setTheme(t) {
|
||||
localStorage.setItem('theme', t);
|
||||
applyTheme(t);
|
||||
highlightTheme(t);
|
||||
}
|
||||
applyTheme(localStorage.getItem('theme') || 'system');
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
||||
if ((localStorage.getItem('theme') || 'system') === 'system') applyTheme('system');
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
highlightTheme(localStorage.getItem('theme') || 'system');
|
||||
});
|
||||
</script>
|
||||
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<style>
|
||||
.btn { --animation-btn: 0; --btn-focus-scale: 1; }
|
||||
#nav-backdrop { display: none; }
|
||||
@media (max-width: 767px) {
|
||||
#nav-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0s linear 0.2s;
|
||||
}
|
||||
.navbar:has(.dropdown:focus-within) ~ #nav-backdrop {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.15s ease, visibility 0s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200 font-sans text-base-content antialiased">
|
||||
<header class="navbar bg-base-100 shadow-sm">
|
||||
<nav class="mx-auto flex w-full max-w-6xl items-center justify-between gap-2 px-4">
|
||||
<a href="/" class="min-w-0 truncate text-lg font-bold">Universal Web</a>
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<a href="/" class="btn btn-ghost btn-sm">Home</a>
|
||||
<a href="/about" class="btn btn-ghost btn-sm">About</a>
|
||||
<a href="/blog" class="btn btn-ghost btn-sm">Blog</a>
|
||||
<a href="/admin/login" class="btn btn-ghost btn-sm">Admin</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="dropdown dropdown-end md:hidden">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Menu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div tabindex="0"
|
||||
class="dropdown-content z-50 mt-3 flex w-52 flex-col gap-1 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||
<a href="/" class="btn btn-ghost btn-sm justify-start">Home</a>
|
||||
<a href="/about" class="btn btn-ghost btn-sm justify-start">About</a>
|
||||
<a href="/blog" class="btn btn-ghost btn-sm justify-start">Blog</a>
|
||||
<a href="/admin/login" class="btn btn-ghost btn-sm justify-start">Admin</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm btn-circle" aria-label="Settings" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="h-5 w-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="menu dropdown-content z-50 mt-3 w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
|
||||
<li class="menu-title">Theme</li>
|
||||
<li><button type="button" data-theme-opt="system" onclick="setTheme('system')">System <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="light" onclick="setTheme('light')">Light <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
<li><button type="button" data-theme-opt="dark" onclick="setTheme('dark')">Dark <span class="opt-check ml-auto hidden">✓</span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<div id="nav-backdrop" aria-hidden="true"></div>
|
||||
<main class="mx-auto max-w-6xl px-4 py-6">
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
20
assets/views/blog/index.html
Normal file
20
assets/views/blog/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Blog{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Blog</h1>
|
||||
|
||||
{% if articles | length > 0 %}
|
||||
<ul>
|
||||
{% for article in articles %}
|
||||
<li>
|
||||
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
||||
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No published posts yet.</p>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
12
assets/views/blog/show.html
Normal file
12
assets/views/blog/show.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ article.title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>{{ article.title }}</h1>
|
||||
<p>Views: {{ article.view_count }}</p>
|
||||
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
|
||||
<div>{{ article.content | linebreaksbr | safe }}</div>
|
||||
</article>
|
||||
{% endblock content %}
|
||||
23
assets/views/home/index.html
Normal file
23
assets/views/home/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Home{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Home</h1>
|
||||
|
||||
<section>
|
||||
<h2>Latest posts</h2>
|
||||
{% if articles | length > 0 %}
|
||||
<ul>
|
||||
{% for article in articles %}
|
||||
<li>
|
||||
<a href="/blog/{{ article.slug }}">{{ article.title }}</a>
|
||||
{% if article.excerpt %}<p>{{ article.excerpt }}</p>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No published posts yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock content %}
|
||||
10
assets/views/pages/about.html
Normal file
10
assets/views/pages/about.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ page.title }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<div>{{ page.content | linebreaksbr | safe }}</div>
|
||||
</article>
|
||||
{% endblock content %}
|
||||
@@ -71,7 +71,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/universal_web_development") }}
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/universal_web_development") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
@@ -93,7 +93,14 @@ database:
|
||||
auth:
|
||||
# JWT authentication
|
||||
jwt:
|
||||
location:
|
||||
- from: Cookie
|
||||
name: auth_token
|
||||
- from: Bearer
|
||||
# Secret key for token generation and verification
|
||||
secret: A6ECni63rt2Jb00tX9Hf
|
||||
# Token expiration time in seconds
|
||||
expiration: 604800 # 7 days
|
||||
|
||||
settings:
|
||||
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
|
||||
|
||||
@@ -68,7 +68,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# Database connection URI
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/universal_web_test") }}
|
||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/universal_web_test") }}
|
||||
# When enabled, the sql query will be logged.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
@@ -90,7 +90,14 @@ database:
|
||||
auth:
|
||||
# JWT authentication
|
||||
jwt:
|
||||
location:
|
||||
- from: Cookie
|
||||
name: auth_token
|
||||
- from: Bearer
|
||||
# Secret key for token generation and verification
|
||||
secret: 0yWwoflcGiAhonIzhQyQ
|
||||
# Token expiration time in seconds
|
||||
expiration: 604800 # 7 days
|
||||
|
||||
settings:
|
||||
admin_email: admin@example.com
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
#![allow(clippy::wildcard_imports)]
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
mod m20220101_000001_users;
|
||||
mod m20260517_000001_add_theme_to_users;
|
||||
mod m20260517_000002_user_roles;
|
||||
mod m20260517_000003_blog_articles;
|
||||
mod m20260517_000004_audit_logs;
|
||||
mod m20260517_000005_audio_albums;
|
||||
mod m20260517_000006_audio_tracks;
|
||||
mod m20260517_000007_audio_tags;
|
||||
mod m20260517_000008_audio_track_tags;
|
||||
mod m20260517_000009_simple_constraints;
|
||||
mod m20260517_000010_drop_user_roles;
|
||||
mod m20260517_000011_site_pages;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -10,6 +21,17 @@ impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![
|
||||
Box::new(m20220101_000001_users::Migration),
|
||||
Box::new(m20260517_000001_add_theme_to_users::Migration),
|
||||
Box::new(m20260517_000002_user_roles::Migration),
|
||||
Box::new(m20260517_000003_blog_articles::Migration),
|
||||
Box::new(m20260517_000004_audit_logs::Migration),
|
||||
Box::new(m20260517_000005_audio_albums::Migration),
|
||||
Box::new(m20260517_000006_audio_tracks::Migration),
|
||||
Box::new(m20260517_000007_audio_tags::Migration),
|
||||
Box::new(m20260517_000008_audio_track_tags::Migration),
|
||||
Box::new(m20260517_000009_simple_constraints::Migration),
|
||||
Box::new(m20260517_000010_drop_user_roles::Migration),
|
||||
Box::new(m20260517_000011_site_pages::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
24
migration/src/m20260517_000001_add_theme_to_users.rs
Normal file
24
migration/src/m20260517_000001_add_theme_to_users.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use loco_rs::schema::*;
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
add_column(
|
||||
m,
|
||||
"users",
|
||||
"theme",
|
||||
ColType::StringLenWithDefault(20, "light".to_string()),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
remove_column(m, "users", "theme").await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
97
migration/src/m20260517_000002_user_roles.rs
Normal file
97
migration/src/m20260517_000002_user_roles.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum UserRoles {
|
||||
Table,
|
||||
UserId,
|
||||
Role,
|
||||
AssignedBy,
|
||||
AssignedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(UserRoles::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(UserRoles::UserId).integer().not_null())
|
||||
.col(ColumnDef::new(UserRoles::Role).string_len(50).not_null())
|
||||
.col(ColumnDef::new(UserRoles::AssignedBy).integer().null())
|
||||
.col(
|
||||
ColumnDef::new(UserRoles::AssignedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.name("pk-user_roles")
|
||||
.col(UserRoles::UserId)
|
||||
.col(UserRoles::Role),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-user_roles-user_id-to-users")
|
||||
.from(UserRoles::Table, UserRoles::UserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-user_roles-assigned_by-to-users")
|
||||
.from(UserRoles::Table, UserRoles::AssignedBy)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::SetNull)
|
||||
.on_update(ForeignKeyAction::NoAction),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name("idx-user_roles-user_id")
|
||||
.table(UserRoles::Table)
|
||||
.col(UserRoles::UserId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sql = match m.get_database_backend() {
|
||||
sea_orm_migration::sea_orm::DatabaseBackend::Postgres => {
|
||||
"INSERT INTO user_roles (user_id, role, assigned_at) \
|
||||
SELECT id, 'user', CURRENT_TIMESTAMP FROM users \
|
||||
ON CONFLICT (user_id, role) DO NOTHING"
|
||||
}
|
||||
sea_orm_migration::sea_orm::DatabaseBackend::Sqlite => {
|
||||
"INSERT OR IGNORE INTO user_roles (user_id, role, assigned_at) \
|
||||
SELECT id, 'user', CURRENT_TIMESTAMP FROM users"
|
||||
}
|
||||
sea_orm_migration::sea_orm::DatabaseBackend::MySql => {
|
||||
"INSERT IGNORE INTO user_roles (user_id, role, assigned_at) \
|
||||
SELECT id, 'user', CURRENT_TIMESTAMP FROM users"
|
||||
}
|
||||
};
|
||||
m.get_connection().execute_unprepared(sql).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(UserRoles::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
126
migration/src/m20260517_000003_blog_articles.rs
Normal file
126
migration/src/m20260517_000003_blog_articles.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use sea_orm_migration::{prelude::*, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum BlogArticles {
|
||||
Table,
|
||||
Id,
|
||||
Title,
|
||||
Slug,
|
||||
Content,
|
||||
Excerpt,
|
||||
Published,
|
||||
AuthorId,
|
||||
FeaturedImageId,
|
||||
ViewCount,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
PublishedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(BlogArticles::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(BlogArticles::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(BlogArticles::Title).string_len(500).not_null())
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::Slug)
|
||||
.string_len(500)
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(BlogArticles::Content).text().not_null())
|
||||
.col(ColumnDef::new(BlogArticles::Excerpt).string_len(1000).null())
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::Published)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(ColumnDef::new(BlogArticles::AuthorId).integer().not_null())
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::FeaturedImageId)
|
||||
.string_len(500)
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::ViewCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(BlogArticles::PublishedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-blog_articles-author_id-to-users")
|
||||
.from(BlogArticles::Table, BlogArticles::AuthorId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
create_index(m, "idx-blog_articles-slug", BlogArticles::Slug).await?;
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name("idx-blog_articles-published-published_at")
|
||||
.table(BlogArticles::Table)
|
||||
.col(BlogArticles::Published)
|
||||
.col(BlogArticles::PublishedAt)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
create_index(m, "idx-blog_articles-author_id", BlogArticles::AuthorId).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(BlogArticles::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_index<T>(m: &SchemaManager<'_>, name: &str, col: T) -> Result<(), DbErr>
|
||||
where
|
||||
T: Iden + 'static,
|
||||
{
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name(name)
|
||||
.table(BlogArticles::Table)
|
||||
.col(col)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
93
migration/src/m20260517_000004_audit_logs.rs
Normal file
93
migration/src/m20260517_000004_audit_logs.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use sea_orm_migration::{prelude::*, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AuditLogs {
|
||||
Table,
|
||||
Id,
|
||||
AdminUserId,
|
||||
Action,
|
||||
TargetType,
|
||||
TargetId,
|
||||
Details,
|
||||
IpAddress,
|
||||
UserAgent,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(AuditLogs::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(AuditLogs::AdminUserId).integer().not_null())
|
||||
.col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null())
|
||||
.col(ColumnDef::new(AuditLogs::TargetType).string_len(50).null())
|
||||
.col(ColumnDef::new(AuditLogs::TargetId).uuid().null())
|
||||
.col(ColumnDef::new(AuditLogs::Details).json_binary().null())
|
||||
.col(ColumnDef::new(AuditLogs::IpAddress).inet().null())
|
||||
.col(ColumnDef::new(AuditLogs::UserAgent).text().null())
|
||||
.col(
|
||||
ColumnDef::new(AuditLogs::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-audit_logs-admin_user_id-to-users")
|
||||
.from(AuditLogs::Table, AuditLogs::AdminUserId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
create_index(m, "idx-audit_logs-admin_user_id", AuditLogs::AdminUserId).await?;
|
||||
create_index(m, "idx-audit_logs-action", AuditLogs::Action).await?;
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name("idx-audit_logs-target")
|
||||
.table(AuditLogs::Table)
|
||||
.col(AuditLogs::TargetType)
|
||||
.col(AuditLogs::TargetId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
create_index(m, "idx-audit_logs-created_at", AuditLogs::CreatedAt).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(AuditLogs::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_index<T>(m: &SchemaManager<'_>, name: &str, col: T) -> Result<(), DbErr>
|
||||
where
|
||||
T: Iden + 'static,
|
||||
{
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name(name)
|
||||
.table(AuditLogs::Table)
|
||||
.col(col)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
120
migration/src/m20260517_000005_audio_albums.rs
Normal file
120
migration/src/m20260517_000005_audio_albums.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use sea_orm_migration::{prelude::*, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioAlbums {
|
||||
Table,
|
||||
Id,
|
||||
Title,
|
||||
Slug,
|
||||
Description,
|
||||
CoverImageId,
|
||||
Artist,
|
||||
ReleaseDate,
|
||||
Published,
|
||||
UploaderId,
|
||||
ViewCount,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
PublishedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(AudioAlbums::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(AudioAlbums::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(AudioAlbums::Title).string_len(500).not_null())
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::Slug)
|
||||
.string_len(500)
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(AudioAlbums::Description).text().null())
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::CoverImageId)
|
||||
.string_len(500)
|
||||
.null(),
|
||||
)
|
||||
.col(ColumnDef::new(AudioAlbums::Artist).string_len(500).null())
|
||||
.col(ColumnDef::new(AudioAlbums::ReleaseDate).date().null())
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::Published)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(ColumnDef::new(AudioAlbums::UploaderId).integer().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::ViewCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioAlbums::PublishedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.null(),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-audio_albums-uploader_id-to-users")
|
||||
.from(AudioAlbums::Table, AudioAlbums::UploaderId)
|
||||
.to(Users::Table, Users::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
create_index(m, "idx-audio_albums-slug", AudioAlbums::Slug).await?;
|
||||
create_index(m, "idx-audio_albums-published", AudioAlbums::Published).await?;
|
||||
create_index(m, "idx-audio_albums-uploader_id", AudioAlbums::UploaderId).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(AudioAlbums::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_index<T>(m: &SchemaManager<'_>, name: &str, col: T) -> Result<(), DbErr>
|
||||
where
|
||||
T: Iden + 'static,
|
||||
{
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name(name)
|
||||
.table(AudioAlbums::Table)
|
||||
.col(col)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
100
migration/src/m20260517_000006_audio_tracks.rs
Normal file
100
migration/src/m20260517_000006_audio_tracks.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use sea_orm_migration::{prelude::*, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioTracks {
|
||||
Table,
|
||||
Id,
|
||||
AlbumId,
|
||||
Title,
|
||||
Slug,
|
||||
AudioFileId,
|
||||
TrackNumber,
|
||||
Duration,
|
||||
Featured,
|
||||
PlayCount,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioAlbums {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(AudioTracks::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(AudioTracks::Id).uuid().not_null().primary_key())
|
||||
.col(ColumnDef::new(AudioTracks::AlbumId).uuid().not_null())
|
||||
.col(ColumnDef::new(AudioTracks::Title).string_len(500).not_null())
|
||||
.col(ColumnDef::new(AudioTracks::Slug).string_len(500).not_null())
|
||||
.col(
|
||||
ColumnDef::new(AudioTracks::AudioFileId)
|
||||
.string_len(500)
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(AudioTracks::TrackNumber).integer().null())
|
||||
.col(ColumnDef::new(AudioTracks::Duration).integer().null())
|
||||
.col(
|
||||
ColumnDef::new(AudioTracks::Featured)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioTracks::PlayCount)
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioTracks::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioTracks::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-audio_tracks-album_id-to-audio_albums")
|
||||
.from(AudioTracks::Table, AudioTracks::AlbumId)
|
||||
.to(AudioAlbums::Table, AudioAlbums::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name("idx-audio_tracks-album_id-slug")
|
||||
.table(AudioTracks::Table)
|
||||
.col(AudioTracks::AlbumId)
|
||||
.col(AudioTracks::Slug)
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(AudioTracks::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
52
migration/src/m20260517_000007_audio_tags.rs
Normal file
52
migration/src/m20260517_000007_audio_tags.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use sea_orm_migration::{prelude::*, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioTags {
|
||||
Table,
|
||||
Id,
|
||||
Name,
|
||||
Slug,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(AudioTags::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(AudioTags::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(AudioTags::Name)
|
||||
.string_len(100)
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioTags::Slug)
|
||||
.string_len(100)
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(AudioTags::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(AudioTags::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
74
migration/src/m20260517_000008_audio_track_tags.rs
Normal file
74
migration/src/m20260517_000008_audio_track_tags.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use sea_orm_migration::{prelude::*, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioTrackTags {
|
||||
Table,
|
||||
TrackId,
|
||||
TagId,
|
||||
CreatedAt,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioTracks {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioTags {
|
||||
Table,
|
||||
Id,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(AudioTrackTags::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(AudioTrackTags::TrackId).uuid().not_null())
|
||||
.col(ColumnDef::new(AudioTrackTags::TagId).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(AudioTrackTags::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.name("pk-audio_track_tags")
|
||||
.col(AudioTrackTags::TrackId)
|
||||
.col(AudioTrackTags::TagId),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-audio_track_tags-track_id-to-audio_tracks")
|
||||
.from(AudioTrackTags::Table, AudioTrackTags::TrackId)
|
||||
.to(AudioTracks::Table, AudioTracks::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk-audio_track_tags-tag_id-to-audio_tags")
|
||||
.from(AudioTrackTags::Table, AudioTrackTags::TagId)
|
||||
.to(AudioTags::Table, AudioTags::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(AudioTrackTags::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
60
migration/src/m20260517_000009_simple_constraints.rs
Normal file
60
migration/src/m20260517_000009_simple_constraints.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum AudioTrackTags {
|
||||
Table,
|
||||
TagId,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
if matches!(
|
||||
m.get_database_backend(),
|
||||
sea_orm_migration::sea_orm::DatabaseBackend::Postgres
|
||||
) {
|
||||
m.get_connection()
|
||||
.execute_unprepared(
|
||||
"ALTER TABLE users \
|
||||
ADD CONSTRAINT chk_users_theme \
|
||||
CHECK (theme IN ('light', 'dark'))",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
m.create_index(
|
||||
Index::create()
|
||||
.name("idx-audio_track_tags-tag_id")
|
||||
.table(AudioTrackTags::Table)
|
||||
.col(AudioTrackTags::TagId)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_index(
|
||||
Index::drop()
|
||||
.name("idx-audio_track_tags-tag_id")
|
||||
.table(AudioTrackTags::Table)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if matches!(
|
||||
m.get_database_backend(),
|
||||
sea_orm_migration::sea_orm::DatabaseBackend::Postgres
|
||||
) {
|
||||
m.get_connection()
|
||||
.execute_unprepared("ALTER TABLE users DROP CONSTRAINT chk_users_theme")
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
28
migration/src/m20260517_000010_drop_user_roles.rs
Normal file
28
migration/src/m20260517_000010_drop_user_roles.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum UserRoles {
|
||||
Table,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(
|
||||
Table::drop()
|
||||
.table(UserRoles::Table)
|
||||
.if_exists()
|
||||
.cascade()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, _m: &SchemaManager) -> Result<(), DbErr> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
65
migration/src/m20260517_000011_site_pages.rs
Normal file
65
migration/src/m20260517_000011_site_pages.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use sea_orm_migration::{prelude::*, sea_orm::ConnectionTrait, sea_query::Expr};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum SitePages {
|
||||
Table,
|
||||
Id,
|
||||
Slug,
|
||||
Title,
|
||||
Content,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.create_table(
|
||||
Table::create()
|
||||
.table(SitePages::Table)
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(SitePages::Id).uuid().not_null().primary_key())
|
||||
.col(
|
||||
ColumnDef::new(SitePages::Slug)
|
||||
.string_len(100)
|
||||
.not_null()
|
||||
.unique_key(),
|
||||
)
|
||||
.col(ColumnDef::new(SitePages::Title).string_len(500).not_null())
|
||||
.col(ColumnDef::new(SitePages::Content).text().not_null())
|
||||
.col(
|
||||
ColumnDef::new(SitePages::CreatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(SitePages::UpdatedAt)
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
m.get_connection()
|
||||
.execute_unprepared(
|
||||
"INSERT INTO site_pages (id, slug, title, content, created_at, updated_at) \
|
||||
VALUES (gen_random_uuid(), 'about', 'About', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) \
|
||||
ON CONFLICT (slug) DO NOTHING",
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.drop_table(Table::drop().table(SitePages::Table).to_owned())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,10 @@ impl Hooks for App {
|
||||
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||
AppRoutes::with_default_routes() // controller routes below
|
||||
.add_route(controllers::auth::routes())
|
||||
.add_route(controllers::admin::routes())
|
||||
.add_route(controllers::blog::routes())
|
||||
.add_route(controllers::pages::routes())
|
||||
.add_route(controllers::frontend::routes())
|
||||
}
|
||||
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||
queue.register(DownloadWorker::build(ctx)).await?;
|
||||
|
||||
57
src/controllers/admin.rs
Normal file
57
src/controllers/admin.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::models::{
|
||||
_entities::{audio_albums, audio_tracks, audit_logs, blog_articles, users},
|
||||
users as users_model,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DashboardResponse {
|
||||
users: u64,
|
||||
blog_articles: u64,
|
||||
audio_albums: u64,
|
||||
audio_tracks: u64,
|
||||
audit_logs: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
||||
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
|
||||
if !is_admin(ctx, &user) {
|
||||
return unauthorized("admin only");
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
current_admin(auth, &ctx).await?;
|
||||
|
||||
format::json(DashboardResponse {
|
||||
users: users::Entity::find().count(&ctx.db).await?,
|
||||
blog_articles: blog_articles::Entity::find().count(&ctx.db).await?,
|
||||
audio_albums: audio_albums::Entity::find().count(&ctx.db).await?,
|
||||
audio_tracks: audio_tracks::Entity::find().count(&ctx.db).await?,
|
||||
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
.add("/dashboard", get(dashboard))
|
||||
}
|
||||
@@ -6,12 +6,15 @@ use crate::{
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
use time::Duration as TimeDuration;
|
||||
|
||||
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
||||
const AUTH_COOKIE: &str = "auth_token";
|
||||
|
||||
fn get_allow_email_domain_re() -> &'static Regex {
|
||||
EMAIL_DOMAIN_RE.get_or_init(|| {
|
||||
@@ -19,6 +22,36 @@ fn get_allow_email_domain_re() -> &'static Regex {
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
|
||||
Cookie::build((AUTH_COOKIE, token.to_string()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(max_age_seconds as i64))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_auth_cookie() -> Cookie<'static> {
|
||||
Cookie::build((AUTH_COOKIE, ""))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ForgotParams {
|
||||
pub email: String,
|
||||
@@ -155,13 +188,20 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
format::render()
|
||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
format::json(CurrentResponse::new(&user))
|
||||
format::json(CurrentResponse::new(&user, is_admin(&ctx, &user)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn logout() -> Result<Response> {
|
||||
format::render().cookies(&[clear_auth_cookie()])?.json(())
|
||||
}
|
||||
|
||||
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||
@@ -223,7 +263,9 @@ async fn magic_link_verify(
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::json(LoginResponse::new(&user, &token))
|
||||
format::render()
|
||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -264,6 +306,7 @@ pub fn routes() -> Routes {
|
||||
.add("/register", post(register))
|
||||
.add("/verify/{token}", get(verify))
|
||||
.add("/login", post(login))
|
||||
.add("/logout", post(logout))
|
||||
.add("/forgot", post(forgot))
|
||||
.add("/reset", post(reset))
|
||||
.add("/current", get(current))
|
||||
|
||||
236
src/controllers/blog.rs
Normal file
236
src/controllers/blog.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use crate::{
|
||||
controllers::admin,
|
||||
models::_entities::blog_articles,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArticleParams {
|
||||
title: String,
|
||||
content: String,
|
||||
excerpt: Option<String>,
|
||||
published: Option<bool>,
|
||||
featured_image_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ArticleResponse {
|
||||
id: Uuid,
|
||||
title: String,
|
||||
slug: String,
|
||||
content: String,
|
||||
excerpt: Option<String>,
|
||||
published: bool,
|
||||
author_id: i32,
|
||||
featured_image_id: Option<String>,
|
||||
view_count: i32,
|
||||
created_at: chrono::DateTime<chrono::FixedOffset>,
|
||||
updated_at: chrono::DateTime<chrono::FixedOffset>,
|
||||
published_at: Option<chrono::DateTime<chrono::FixedOffset>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ArticleListResponse {
|
||||
articles: Vec<ArticleResponse>,
|
||||
}
|
||||
|
||||
impl From<blog_articles::Model> for ArticleResponse {
|
||||
fn from(article: blog_articles::Model) -> Self {
|
||||
Self {
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
content: article.content,
|
||||
excerpt: article.excerpt,
|
||||
published: article.published,
|
||||
author_id: article.author_id,
|
||||
featured_image_id: article.featured_image_id,
|
||||
view_count: article.view_count,
|
||||
created_at: article.created_at,
|
||||
updated_at: article.updated_at,
|
||||
published_at: article.published_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(title: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in title.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
Uuid::new_v4().to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
||||
published.then(|| Utc::now().into())
|
||||
}
|
||||
|
||||
async fn find_article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
|
||||
blog_articles::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn public_index(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let articles = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(ArticleResponse::from)
|
||||
.collect();
|
||||
|
||||
format::json(ArticleListResponse { articles })
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn public_show(Path(slug): Path<String>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
let article = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Slug.eq(slug))
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let mut active = article.into_active_model();
|
||||
let next_count = active.view_count.as_ref().to_owned() + 1;
|
||||
active.view_count = Set(next_count);
|
||||
let article = active.update(&ctx.db).await?;
|
||||
|
||||
format::json(ArticleResponse::from(article))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_index(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
|
||||
let articles = blog_articles::Entity::find()
|
||||
.order_by_desc(blog_articles::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(ArticleResponse::from)
|
||||
.collect();
|
||||
|
||||
format::json(ArticleListResponse { articles })
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ArticleParams>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
let published = params.published.unwrap_or(false);
|
||||
|
||||
let article = blog_articles::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
title: Set(params.title.clone()),
|
||||
slug: Set(slugify(¶ms.title)),
|
||||
content: Set(params.content),
|
||||
excerpt: Set(params.excerpt),
|
||||
published: Set(published),
|
||||
author_id: Set(admin_user.id),
|
||||
featured_image_id: Set(params.featured_image_id),
|
||||
view_count: Set(0),
|
||||
published_at: Set(published_at_for(published)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::json(ArticleResponse::from(article))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<ArticleParams>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
|
||||
let existing = find_article_by_id(&ctx, id).await?;
|
||||
let was_published = existing.published;
|
||||
let published = params.published.unwrap_or(was_published);
|
||||
|
||||
let mut article = existing.into_active_model();
|
||||
article.title = Set(params.title.clone());
|
||||
article.slug = Set(slugify(¶ms.title));
|
||||
article.content = Set(params.content);
|
||||
article.excerpt = Set(params.excerpt);
|
||||
article.published = Set(published);
|
||||
article.featured_image_id = Set(params.featured_image_id);
|
||||
if published && !was_published {
|
||||
article.published_at = Set(published_at_for(true));
|
||||
} else if !published {
|
||||
article.published_at = Set(None);
|
||||
}
|
||||
|
||||
let article = article.update(&ctx.db).await?;
|
||||
format::json(ArticleResponse::from(article))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_delete(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let article = find_article_by_id(&ctx, id).await?;
|
||||
article.delete(&ctx.db).await?;
|
||||
format::json(())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_publish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
|
||||
article.published = Set(true);
|
||||
article.published_at = Set(published_at_for(true));
|
||||
let article = article.update(&ctx.db).await?;
|
||||
format::json(ArticleResponse::from(article))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_unpublish(auth: auth::JWT, Path(id): Path<Uuid>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut article = find_article_by_id(&ctx, id).await?.into_active_model();
|
||||
article.published = Set(false);
|
||||
article.published_at = Set(None);
|
||||
let article = article.update(&ctx.db).await?;
|
||||
format::json(ArticleResponse::from(article))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api")
|
||||
.add("/blog", get(public_index))
|
||||
.add("/blog/{slug}", get(public_show))
|
||||
.add("/admin/blog/articles", get(admin_index))
|
||||
.add("/admin/blog/articles", post(admin_create))
|
||||
.add("/admin/blog/articles/{id}", put(admin_update))
|
||||
.add("/admin/blog/articles/{id}", delete(admin_delete))
|
||||
.add("/admin/blog/articles/{id}/publish", post(admin_publish))
|
||||
.add("/admin/blog/articles/{id}/unpublish", post(admin_unpublish))
|
||||
}
|
||||
344
src/controllers/frontend.rs
Normal file
344
src/controllers/frontend.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use crate::{
|
||||
controllers::{admin, auth as auth_controller},
|
||||
models::{
|
||||
_entities::{blog_articles, site_pages},
|
||||
users::{self, LoginParams},
|
||||
},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ABOUT_SLUG: &str = "about";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArticleForm {
|
||||
title: String,
|
||||
content: String,
|
||||
excerpt: Option<String>,
|
||||
published: Option<String>,
|
||||
featured_image_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AboutForm {
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
fn slugify(title: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in title.chars().flat_map(char::to_lowercase) {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch);
|
||||
last_was_dash = false;
|
||||
} else if !last_was_dash && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
last_was_dash = true;
|
||||
}
|
||||
}
|
||||
|
||||
let slug = slug.trim_matches('-').to_string();
|
||||
if slug.is_empty() {
|
||||
Uuid::new_v4().to_string()
|
||||
} else {
|
||||
slug
|
||||
}
|
||||
}
|
||||
|
||||
fn published_at_for(published: bool) -> Option<chrono::DateTime<chrono::FixedOffset>> {
|
||||
published.then(|| Utc::now().into())
|
||||
}
|
||||
|
||||
fn is_checked(value: &Option<String>) -> bool {
|
||||
value.as_deref().is_some_and(|value| value == "on" || value == "true")
|
||||
}
|
||||
|
||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn about_page(ctx: &AppContext) -> Result<site_pages::Model> {
|
||||
site_pages::Entity::find()
|
||||
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
async fn article_by_id(ctx: &AppContext, id: Uuid) -> Result<blog_articles::Model> {
|
||||
blog_articles::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn home(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let articles = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
||||
.limit(5)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(&v, "home/index.html", json!({ "articles": articles }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn about(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
format::view(&v, "pages/about.html", json!({ "page": about_page(&ctx).await? }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn blog_index(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let articles = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.order_by_desc(blog_articles::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::view(&v, "blog/index.html", json!({ "articles": articles }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn blog_show(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let article = blog_articles::Entity::find()
|
||||
.filter(blog_articles::Column::Slug.eq(slug))
|
||||
.filter(blog_articles::Column::Published.eq(true))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
let mut active = article.into_active_model();
|
||||
let next_count = active.view_count.as_ref().to_owned() + 1;
|
||||
active.view_count = Set(next_count);
|
||||
let article = active.update(&ctx.db).await?;
|
||||
|
||||
format::view(&v, "blog/show.html", json!({ "article": article }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login_page(ViewEngine(v): ViewEngine<TeraView>) -> Result<Response> {
|
||||
format::view(&v, "admin/login.html", json!({ "error": null }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login(
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<LoginParams>,
|
||||
) -> Result<Response> {
|
||||
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) {
|
||||
return format::view(&v, "admin/login.html", json!({ "error": "Invalid credentials" }));
|
||||
}
|
||||
|
||||
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||
let token = user
|
||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
||||
.redirect("/admin/dashboard")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_logout() -> Result<Response> {
|
||||
format::render()
|
||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||
.redirect("/admin/login")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_home(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
format::view(&v, "admin/index.html", json!({ "admin": admin_user }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_about(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(&v, "admin/about.html", json!({ "page": about_page(&ctx).await? }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_about_update(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<AboutForm>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut page = about_page(&ctx).await?.into_active_model();
|
||||
page.title = Set(params.title);
|
||||
page.content = Set(params.content);
|
||||
page.update(&ctx.db).await?;
|
||||
format::redirect("/admin/about")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_articles(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let articles = blog_articles::Entity::find()
|
||||
.order_by_desc(blog_articles::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
format::view(&v, "admin/blog/index.html", json!({ "articles": articles }))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_new(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(&v, "admin/blog/new.html", json!({}))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<ArticleForm>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::current_admin(auth, &ctx).await?;
|
||||
let published = is_checked(¶ms.published);
|
||||
|
||||
blog_articles::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
title: Set(params.title.clone()),
|
||||
slug: Set(slugify(¶ms.title)),
|
||||
content: Set(params.content),
|
||||
excerpt: Set(normalize_empty(params.excerpt)),
|
||||
published: Set(published),
|
||||
author_id: Set(admin_user.id),
|
||||
featured_image_id: Set(normalize_empty(params.featured_image_id)),
|
||||
view_count: Set(0),
|
||||
published_at: Set(published_at_for(published)),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
|
||||
format::redirect("/admin/blog/articles")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_edit(
|
||||
auth: auth::JWT,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
format::view(
|
||||
&v,
|
||||
"admin/blog/edit.html",
|
||||
json!({ "article": article_by_id(&ctx, id).await? }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(params): Form<ArticleForm>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let existing = article_by_id(&ctx, id).await?;
|
||||
let was_published = existing.published;
|
||||
let published = is_checked(¶ms.published);
|
||||
|
||||
let mut article = existing.into_active_model();
|
||||
article.title = Set(params.title.clone());
|
||||
article.slug = Set(slugify(¶ms.title));
|
||||
article.content = Set(params.content);
|
||||
article.excerpt = Set(normalize_empty(params.excerpt));
|
||||
article.published = Set(published);
|
||||
article.featured_image_id = Set(normalize_empty(params.featured_image_id));
|
||||
if published && !was_published {
|
||||
article.published_at = Set(published_at_for(true));
|
||||
} else if !published {
|
||||
article.published_at = Set(None);
|
||||
}
|
||||
article.update(&ctx.db).await?;
|
||||
|
||||
format::redirect("/admin/blog/articles")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_article_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<Uuid>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
article_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/blog/articles")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/", get(home))
|
||||
.add("/about", get(about))
|
||||
.add("/blog", get(blog_index))
|
||||
.add("/blog/{slug}", get(blog_show))
|
||||
.add("/admin/login", get(admin_login_page))
|
||||
.add("/admin/login", post(admin_login))
|
||||
.add("/admin/logout", post(admin_logout))
|
||||
.add("/admin", get(admin_login_page))
|
||||
.add("/admin/dashboard", get(admin_home))
|
||||
.add("/admin/about", get(admin_about))
|
||||
.add("/admin/about", post(admin_about_update))
|
||||
.add("/admin/blog/articles", get(admin_articles))
|
||||
.add("/admin/blog/articles/new", get(admin_article_new))
|
||||
.add("/admin/blog/articles", post(admin_article_create))
|
||||
.add("/admin/blog/articles/{id}/edit", get(admin_article_edit))
|
||||
.add("/admin/blog/articles/{id}", post(admin_article_update))
|
||||
.add("/admin/blog/articles/{id}/delete", post(admin_article_delete))
|
||||
}
|
||||
@@ -1 +1,5 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod blog;
|
||||
pub mod frontend;
|
||||
pub mod pages;
|
||||
|
||||
89
src/controllers/pages.rs
Normal file
89
src/controllers/pages.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use crate::{
|
||||
controllers::admin,
|
||||
models::_entities::site_pages,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
const ABOUT_SLUG: &str = "about";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AboutParams {
|
||||
title: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct PageResponse {
|
||||
id: Uuid,
|
||||
slug: String,
|
||||
title: String,
|
||||
content: String,
|
||||
updated_at: chrono::DateTime<chrono::FixedOffset>,
|
||||
}
|
||||
|
||||
impl From<site_pages::Model> for PageResponse {
|
||||
fn from(page: site_pages::Model) -> Self {
|
||||
Self {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
content: page.content,
|
||||
updated_at: page.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_about(ctx: &AppContext) -> Result<site_pages::Model> {
|
||||
site_pages::Entity::find()
|
||||
.filter(site_pages::Column::Slug.eq(ABOUT_SLUG))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn about(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(PageResponse::from(find_about(&ctx).await?))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update_about(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<AboutParams>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
|
||||
let page = match find_about(&ctx).await {
|
||||
Ok(page) => {
|
||||
let mut page = page.into_active_model();
|
||||
page.title = Set(params.title);
|
||||
page.content = Set(params.content);
|
||||
page.update(&ctx.db).await?
|
||||
}
|
||||
Err(Error::NotFound) => {
|
||||
site_pages::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
slug: Set(ABOUT_SLUG.to_string()),
|
||||
title: Set(params.title),
|
||||
content: Set(params.content),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
format::json(PageResponse::from(page))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api")
|
||||
.add("/about", get(about))
|
||||
.add("/admin/about", put(update_about))
|
||||
}
|
||||
@@ -9,7 +9,10 @@ use loco_rs::{
|
||||
use tracing::info;
|
||||
|
||||
const I18N_DIR: &str = "assets/i18n";
|
||||
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
|
||||
// Kept outside `I18N_DIR`: fluent-templates >=0.13 scans top-level *.ftl files
|
||||
// in that dir as locales, and "shared" parses as a langid, so a shared.ftl
|
||||
// living there would be loaded twice and fail with a duplicate-resource error.
|
||||
const I18N_SHARED: &str = "assets/i18n_shared/shared.ftl";
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub struct ViewEngineInitializer;
|
||||
|
||||
|
||||
51
src/models/_entities/audio_albums.rs
Normal file
51
src/models/_entities/audio_albums.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "audio_albums")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
#[sea_orm(unique)]
|
||||
pub slug: String,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
pub cover_image_id: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub release_date: Option<Date>,
|
||||
pub published: bool,
|
||||
pub uploader_id: i32,
|
||||
pub view_count: i32,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
pub published_at: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::audio_tracks::Entity")]
|
||||
AudioTracks,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::UploaderId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::audio_tracks::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioTracks.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
37
src/models/_entities/audio_tags.rs
Normal file
37
src/models/_entities/audio_tags.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "audio_tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub name: String,
|
||||
#[sea_orm(unique)]
|
||||
pub slug: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
|
||||
AudioTrackTags,
|
||||
}
|
||||
|
||||
impl Related<super::audio_track_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioTrackTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::audio_tracks::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
super::audio_track_tags::Relation::AudioTracks.def()
|
||||
}
|
||||
fn via() -> Option<RelationDef> {
|
||||
Some(super::audio_track_tags::Relation::AudioTags.def().rev())
|
||||
}
|
||||
}
|
||||
46
src/models/_entities/audio_track_tags.rs
Normal file
46
src/models/_entities/audio_track_tags.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "audio_track_tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub track_id: Uuid,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tag_id: Uuid,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::audio_tags::Entity",
|
||||
from = "Column::TagId",
|
||||
to = "super::audio_tags::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
AudioTags,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::audio_tracks::Entity",
|
||||
from = "Column::TrackId",
|
||||
to = "super::audio_tracks::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
AudioTracks,
|
||||
}
|
||||
|
||||
impl Related<super::audio_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::audio_tracks::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioTracks.def()
|
||||
}
|
||||
}
|
||||
56
src/models/_entities/audio_tracks.rs
Normal file
56
src/models/_entities/audio_tracks.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "audio_tracks")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub album_id: Uuid,
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub audio_file_id: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub duration: Option<i32>,
|
||||
pub featured: bool,
|
||||
pub play_count: i32,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::audio_albums::Entity",
|
||||
from = "Column::AlbumId",
|
||||
to = "super::audio_albums::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
AudioAlbums,
|
||||
#[sea_orm(has_many = "super::audio_track_tags::Entity")]
|
||||
AudioTrackTags,
|
||||
}
|
||||
|
||||
impl Related<super::audio_albums::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioAlbums.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::audio_track_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioTrackTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::audio_tags::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
super::audio_track_tags::Relation::AudioTags.def()
|
||||
}
|
||||
fn via() -> Option<RelationDef> {
|
||||
Some(super::audio_track_tags::Relation::AudioTracks.def().rev())
|
||||
}
|
||||
}
|
||||
40
src/models/_entities/audit_logs.rs
Normal file
40
src/models/_entities/audit_logs.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "audit_logs")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub admin_user_id: i32,
|
||||
pub action: String,
|
||||
pub target_type: Option<String>,
|
||||
pub target_id: Option<Uuid>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub details: Option<Json>,
|
||||
#[sea_orm(column_type = "custom(\"inet\")", nullable)]
|
||||
pub ip_address: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::AdminUserId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
42
src/models/_entities/blog_articles.rs
Normal file
42
src/models/_entities/blog_articles.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "blog_articles")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
#[sea_orm(unique)]
|
||||
pub slug: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub content: String,
|
||||
pub excerpt: Option<String>,
|
||||
pub published: bool,
|
||||
pub author_id: i32,
|
||||
pub featured_image_id: Option<String>,
|
||||
pub view_count: i32,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
pub published_at: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::users::Entity",
|
||||
from = "Column::AuthorId",
|
||||
to = "super::users::Column::Id",
|
||||
on_update = "Cascade",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Users,
|
||||
}
|
||||
|
||||
impl Related<super::users::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Users.def()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod audio_albums;
|
||||
pub mod audio_tags;
|
||||
pub mod audio_track_tags;
|
||||
pub mod audio_tracks;
|
||||
pub mod audit_logs;
|
||||
pub mod blog_articles;
|
||||
pub mod site_pages;
|
||||
pub mod users;
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
pub use super::audio_albums::Entity as AudioAlbums;
|
||||
pub use super::audio_tags::Entity as AudioTags;
|
||||
pub use super::audio_track_tags::Entity as AudioTrackTags;
|
||||
pub use super::audio_tracks::Entity as AudioTracks;
|
||||
pub use super::audit_logs::Entity as AuditLogs;
|
||||
pub use super::blog_articles::Entity as BlogArticles;
|
||||
pub use super::site_pages::Entity as SitePages;
|
||||
pub use super::users::Entity as Users;
|
||||
|
||||
21
src/models/_entities/site_pages.rs
Normal file
21
src/models/_entities/site_pages.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "site_pages")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
#[sea_orm(unique)]
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub content: String,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -24,7 +24,33 @@ pub struct Model {
|
||||
pub email_verified_at: Option<DateTimeWithTimeZone>,
|
||||
pub magic_link_token: Option<String>,
|
||||
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
|
||||
pub theme: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::audio_albums::Entity")]
|
||||
AudioAlbums,
|
||||
#[sea_orm(has_many = "super::audit_logs::Entity")]
|
||||
AuditLogs,
|
||||
#[sea_orm(has_many = "super::blog_articles::Entity")]
|
||||
BlogArticles,
|
||||
}
|
||||
|
||||
impl Related<super::audio_albums::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AudioAlbums.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::audit_logs::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::AuditLogs.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::blog_articles::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::BlogArticles.def()
|
||||
}
|
||||
}
|
||||
|
||||
28
src/models/audio_albums.rs
Normal file
28
src/models/audio_albums.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::audio_albums::{ActiveModel, Model, Entity};
|
||||
pub type AudioAlbums = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
22
src/models/audio_tags.rs
Normal file
22
src/models/audio_tags.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::audio_tags::{ActiveModel, Model, Entity};
|
||||
pub type AudioTags = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
22
src/models/audio_track_tags.rs
Normal file
22
src/models/audio_track_tags.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::audio_track_tags::{ActiveModel, Model, Entity};
|
||||
pub type AudioTrackTags = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
28
src/models/audio_tracks.rs
Normal file
28
src/models/audio_tracks.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::audio_tracks::{ActiveModel, Model, Entity};
|
||||
pub type AudioTracks = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
22
src/models/audit_logs.rs
Normal file
22
src/models/audit_logs.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::audit_logs::{ActiveModel, Model, Entity};
|
||||
pub type AuditLogs = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
28
src/models/blog_articles.rs
Normal file
28
src/models/blog_articles.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::blog_articles::{ActiveModel, Model, Entity};
|
||||
pub type BlogArticles = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// implement your read-oriented logic here
|
||||
impl Model {}
|
||||
|
||||
// implement your write-oriented logic here
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
@@ -1,2 +1,9 @@
|
||||
pub mod _entities;
|
||||
pub mod users;
|
||||
pub mod audio_tags;
|
||||
pub mod audio_tracks;
|
||||
pub mod audio_track_tags;
|
||||
pub mod audit_logs;
|
||||
pub mod blog_articles;
|
||||
pub mod audio_albums;
|
||||
pub mod site_pages;
|
||||
|
||||
23
src/models/site_pages.rs
Normal file
23
src/models/site_pages.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
pub use super::_entities::site_pages::{ActiveModel, Entity, Model};
|
||||
pub type SitePages = Entity;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {
|
||||
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||
where
|
||||
C: ConnectionTrait,
|
||||
{
|
||||
if !insert && self.updated_at.is_unchanged() {
|
||||
let mut this = self;
|
||||
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||
Ok(this)
|
||||
} else {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {}
|
||||
impl ActiveModel {}
|
||||
impl Entity {}
|
||||
@@ -8,16 +8,18 @@ pub struct LoginResponse {
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub is_verified: bool,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl LoginResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model, token: &String) -> Self {
|
||||
pub fn new(user: &users::Model, token: &String, is_admin: bool) -> Self {
|
||||
Self {
|
||||
token: token.to_string(),
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
is_verified: user.email_verified_at.is_some(),
|
||||
is_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,15 +29,17 @@ pub struct CurrentResponse {
|
||||
pub pid: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
impl CurrentResponse {
|
||||
#[must_use]
|
||||
pub fn new(user: &users::Model) -> Self {
|
||||
pub fn new(user: &users::Model, is_admin: bool) -> Self {
|
||||
Self {
|
||||
pid: user.pid.to_string(),
|
||||
name: user.name.clone(),
|
||||
email: user.email.clone(),
|
||||
is_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user