chore: reorganize project into monorepo
This commit is contained in:
31
.gitignore
vendored
31
.gitignore
vendored
@@ -1,24 +1,11 @@
|
|||||||
# build output
|
.codex/
|
||||||
dist/
|
.vscode/
|
||||||
# generated types
|
.windsurf/
|
||||||
.astro/
|
|
||||||
|
|
||||||
# dependencies
|
frontend/.astro/
|
||||||
node_modules/
|
frontend/dist/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
# logs
|
backend/target/
|
||||||
npm-debug.log*
|
backend/.loco-start.err.log
|
||||||
yarn-debug.log*
|
backend/.loco-start.out.log
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
|
||||||
.env
|
|
||||||
.env.production
|
|
||||||
|
|
||||||
# macOS-specific files
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# jetbrains setting folder
|
|
||||||
.idea/
|
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -1,43 +1,35 @@
|
|||||||
# Astro Starter Kit: Minimal
|
# termi-blog
|
||||||
|
|
||||||
```sh
|
Monorepo for the Termi blog system.
|
||||||
npm create astro@latest -- --template minimal
|
|
||||||
```
|
|
||||||
|
|
||||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
## Structure
|
||||||
|
|
||||||
## 🚀 Project Structure
|
|
||||||
|
|
||||||
Inside of your Astro project, you'll see the following folders and files:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/
|
.
|
||||||
├── public/
|
├─ frontend/ # Astro blog frontend
|
||||||
├── src/
|
├─ backend/ # Loco.rs backend and admin
|
||||||
│ └── pages/
|
├─ .codex/ # Codex workspace config
|
||||||
│ └── index.astro
|
└─ .vscode/ # Editor workspace config
|
||||||
└── package.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
## Run
|
||||||
|
|
||||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
### Frontend
|
||||||
|
|
||||||
Any static assets, like images, can be placed in the `public/` directory.
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
## 🧞 Commands
|
### Backend
|
||||||
|
|
||||||
All commands are run from the root of the project, from a terminal:
|
```powershell
|
||||||
|
cd backend
|
||||||
|
$env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
||||||
|
cargo loco start 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
| Command | Action |
|
## Repo Name
|
||||||
| :------------------------ | :----------------------------------------------- |
|
|
||||||
| `npm install` | Installs dependencies |
|
|
||||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
|
||||||
| `npm run build` | Build your production site to `./dist/` |
|
|
||||||
| `npm run preview` | Preview your build locally, before deploying |
|
|
||||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
|
||||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
|
||||||
|
|
||||||
## 👀 Want to learn more?
|
Recommended repository name: `termi-blog`
|
||||||
|
|
||||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import { defineConfig } from 'astro/config';
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({});
|
|
||||||
11
backend/.cargo/config.toml
Normal file
11
backend/.cargo/config.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[alias]
|
||||||
|
loco = "run --"
|
||||||
|
loco-tool = "run --bin tool --"
|
||||||
|
|
||||||
|
|
||||||
|
playground = "run --example playground"
|
||||||
|
|
||||||
|
# https://github.com/rust-lang/rust/issues/141626
|
||||||
|
# (can be removed once link.exe is fixed)
|
||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "rust-lld"
|
||||||
102
backend/.github/workflows/ci.yaml
vendored
Normal file
102
backend/.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUST_TOOLCHAIN: stable
|
||||||
|
TOOLCHAIN_PROFILE: minimal
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rustfmt:
|
||||||
|
name: Check Style
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
components: rustfmt
|
||||||
|
- name: Run cargo fmt
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: fmt
|
||||||
|
args: --all -- --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
name: Run Clippy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
- name: Setup Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Run cargo clippy
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: clippy
|
||||||
|
args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
postgres:
|
||||||
|
image: postgres
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: postgres_test
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
# Set health checks to wait until postgres has started
|
||||||
|
options: --health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout the code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
toolchain: ${{ env.RUST_TOOLCHAIN }}
|
||||||
|
- name: Setup Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
- name: Run cargo test
|
||||||
|
uses: actions-rs/cargo@v1
|
||||||
|
with:
|
||||||
|
command: test
|
||||||
|
args: --all-features --all
|
||||||
|
env:
|
||||||
|
REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}}
|
||||||
|
DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test
|
||||||
|
|
||||||
20
backend/.gitignore
vendored
Normal file
20
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
**/config/local.yaml
|
||||||
|
**/config/*.local.yaml
|
||||||
|
**/config/production.yaml
|
||||||
|
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# include cargo lock
|
||||||
|
!Cargo.lock
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
2
backend/.rustfmt.toml
Normal file
2
backend/.rustfmt.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
max_width = 100
|
||||||
|
use_small_heuristics = "Default"
|
||||||
5992
backend/Cargo.lock
generated
Normal file
5992
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
backend/Cargo.toml
Normal file
59
backend/Cargo.toml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "termi-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
default-run = "termi_api-cli"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
loco-rs = { version = "0.16" }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
loco-rs = { workspace = true }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = { version = "1" }
|
||||||
|
serde_yaml = { version = "0.9" }
|
||||||
|
tokio = { version = "1.45", default-features = false, features = [
|
||||||
|
"rt-multi-thread",
|
||||||
|
] }
|
||||||
|
async-trait = { version = "0.1" }
|
||||||
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
|
tracing = { version = "0.1" }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
|
regex = { version = "1.11" }
|
||||||
|
migration = { path = "migration" }
|
||||||
|
sea-orm = { version = "1.1", features = [
|
||||||
|
"sqlx-sqlite",
|
||||||
|
"sqlx-postgres",
|
||||||
|
"runtime-tokio-rustls",
|
||||||
|
"macros",
|
||||||
|
] }
|
||||||
|
chrono = { version = "0.4" }
|
||||||
|
validator = { version = "0.20" }
|
||||||
|
uuid = { version = "1.6", features = ["v4"] }
|
||||||
|
include_dir = { version = "0.7" }
|
||||||
|
# view engine i18n
|
||||||
|
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||||
|
unic-langid = { version = "0.9" }
|
||||||
|
# /view engine
|
||||||
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "termi_api-cli"
|
||||||
|
path = "src/bin/main.rs"
|
||||||
|
required-features = []
|
||||||
|
[[bin]]
|
||||||
|
name = "tool"
|
||||||
|
path = "src/bin/tool.rs"
|
||||||
|
required-features = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
loco-rs = { workspace = true, features = ["testing"] }
|
||||||
|
serial_test = { version = "3.1.1" }
|
||||||
|
rstest = { version = "0.25" }
|
||||||
|
insta = { version = "1.34", features = ["redactions", "yaml", "filters"] }
|
||||||
58
backend/README.md
Normal file
58
backend/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Welcome to Loco :train:
|
||||||
|
|
||||||
|
[Loco](https://loco.rs) is a web and API framework running on Rust.
|
||||||
|
|
||||||
|
This is the **SaaS starter** which includes a `User` model and authentication based on JWT.
|
||||||
|
It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server.
|
||||||
|
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo loco start
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
$ cargo loco start
|
||||||
|
Finished dev [unoptimized + debuginfo] target(s) in 21.63s
|
||||||
|
Running `target/debug/myapp start`
|
||||||
|
|
||||||
|
:
|
||||||
|
:
|
||||||
|
:
|
||||||
|
|
||||||
|
controller/app_routes.rs:203: [Middleware] Adding log trace id
|
||||||
|
|
||||||
|
▄ ▀
|
||||||
|
▀ ▄
|
||||||
|
▄ ▀ ▄ ▄ ▄▀
|
||||||
|
▄ ▀▄▄
|
||||||
|
▄ ▀ ▀ ▀▄▀█▄
|
||||||
|
▀█▄
|
||||||
|
▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█
|
||||||
|
██████ █████ ███ █████ ███ █████ ███ ▀█
|
||||||
|
██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄
|
||||||
|
██████ █████ ███ █████ █████ ███ ████▄
|
||||||
|
██████ █████ ███ █████ ▄▄▄ █████ ███ █████
|
||||||
|
██████ █████ ███ ████ ███ █████ ███ ████▀
|
||||||
|
▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀
|
||||||
|
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||||
|
https://loco.rs
|
||||||
|
|
||||||
|
environment: development
|
||||||
|
database: automigrate
|
||||||
|
logger: debug
|
||||||
|
compilation: debug
|
||||||
|
modes: server
|
||||||
|
|
||||||
|
listening on http://localhost:5150
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Stack Serving
|
||||||
|
|
||||||
|
You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections.
|
||||||
|
|
||||||
|
|
||||||
|
## Getting help
|
||||||
|
|
||||||
|
Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/).
|
||||||
48
backend/assets/seeds/comments.yaml
Normal file
48
backend/assets/seeds/comments.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
- id: 1
|
||||||
|
pid: 1
|
||||||
|
author: "Alice"
|
||||||
|
email: "alice@example.com"
|
||||||
|
content: "Great introduction! Looking forward to more content."
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
pid: 1
|
||||||
|
author: "Bob"
|
||||||
|
email: "bob@example.com"
|
||||||
|
content: "The terminal UI looks amazing. Love the design!"
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
pid: 2
|
||||||
|
author: "Charlie"
|
||||||
|
email: "charlie@example.com"
|
||||||
|
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
pid: 3
|
||||||
|
author: "Diana"
|
||||||
|
email: "diana@example.com"
|
||||||
|
content: "Astro is indeed fast. I've been using it for my personal blog too."
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
pid: 4
|
||||||
|
author: "Eve"
|
||||||
|
email: "eve@example.com"
|
||||||
|
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
pid: 5
|
||||||
|
author: "Frank"
|
||||||
|
email: "frank@example.com"
|
||||||
|
content: "Loco.rs looks promising. Might use it for my next project."
|
||||||
|
approved: false
|
||||||
|
|
||||||
|
- id: 7
|
||||||
|
pid: 2
|
||||||
|
author: "Grace"
|
||||||
|
email: "grace@example.com"
|
||||||
|
content: "Would love to see more advanced Rust patterns in future posts."
|
||||||
|
approved: true
|
||||||
38
backend/assets/seeds/friend_links.yaml
Normal file
38
backend/assets/seeds/friend_links.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
- id: 1
|
||||||
|
site_name: "Tech Blog Daily"
|
||||||
|
site_url: "https://techblog.example.com"
|
||||||
|
avatar_url: "https://techblog.example.com/avatar.png"
|
||||||
|
description: "Daily tech news and tutorials"
|
||||||
|
category: "tech"
|
||||||
|
status: "approved"
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
site_name: "Rustacean Station"
|
||||||
|
site_url: "https://rustacean.example.com"
|
||||||
|
avatar_url: "https://rustacean.example.com/logo.png"
|
||||||
|
description: "All things Rust programming"
|
||||||
|
category: "tech"
|
||||||
|
status: "approved"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
site_name: "Design Patterns"
|
||||||
|
site_url: "https://designpatterns.example.com"
|
||||||
|
avatar_url: "https://designpatterns.example.com/icon.png"
|
||||||
|
description: "UI/UX design inspiration"
|
||||||
|
category: "design"
|
||||||
|
status: "approved"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
site_name: "Code Snippets"
|
||||||
|
site_url: "https://codesnippets.example.com"
|
||||||
|
description: "Useful code snippets for developers"
|
||||||
|
category: "dev"
|
||||||
|
status: "pending"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
site_name: "Web Dev Weekly"
|
||||||
|
site_url: "https://webdevweekly.example.com"
|
||||||
|
avatar_url: "https://webdevweekly.example.com/favicon.png"
|
||||||
|
description: "Weekly web development newsletter"
|
||||||
|
category: "dev"
|
||||||
|
status: "pending"
|
||||||
191
backend/assets/seeds/posts.yaml
Normal file
191
backend/assets/seeds/posts.yaml
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
- id: 1
|
||||||
|
pid: 1
|
||||||
|
title: "Welcome to Termi Blog"
|
||||||
|
slug: "welcome-to-termi"
|
||||||
|
content: |
|
||||||
|
# Welcome to Termi Blog
|
||||||
|
|
||||||
|
This is the first post on our new blog built with Astro and Loco.rs backend.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Fast performance with Astro
|
||||||
|
- 🎨 Terminal-style UI design
|
||||||
|
- 💬 Comments system
|
||||||
|
- 🔗 Friend links
|
||||||
|
- 🏷️ Tags and categories
|
||||||
|
|
||||||
|
## Code Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
println!("Hello, Termi!");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stay tuned for more posts!
|
||||||
|
excerpt: "Welcome to our new blog built with Astro and Loco.rs backend."
|
||||||
|
category: "general"
|
||||||
|
published: true
|
||||||
|
pinned: true
|
||||||
|
tags:
|
||||||
|
- welcome
|
||||||
|
- astro
|
||||||
|
- loco-rs
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
pid: 2
|
||||||
|
title: "Rust Programming Tips"
|
||||||
|
slug: "rust-programming-tips"
|
||||||
|
content: |
|
||||||
|
# Rust Programming Tips
|
||||||
|
|
||||||
|
Here are some essential tips for Rust developers:
|
||||||
|
|
||||||
|
## 1. Ownership and Borrowing
|
||||||
|
|
||||||
|
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
||||||
|
|
||||||
|
## 2. Pattern Matching
|
||||||
|
|
||||||
|
Use `match` expressions for exhaustive pattern matching:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match result {
|
||||||
|
Ok(value) => println!("Success: {}", value),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Error Handling
|
||||||
|
|
||||||
|
Use `Result` and `Option` types effectively with the `?` operator.
|
||||||
|
|
||||||
|
Happy coding!
|
||||||
|
excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling."
|
||||||
|
category: "tech"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- rust
|
||||||
|
- programming
|
||||||
|
- tips
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
pid: 3
|
||||||
|
title: "Building a Blog with Astro"
|
||||||
|
slug: "building-blog-with-astro"
|
||||||
|
content: |
|
||||||
|
# Building a Blog with Astro
|
||||||
|
|
||||||
|
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||||
|
|
||||||
|
## Why Astro?
|
||||||
|
|
||||||
|
- **Zero JavaScript by default**: Ships less JavaScript to the client
|
||||||
|
- **Island Architecture**: Hydrate only interactive components
|
||||||
|
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
|
||||||
|
- **Great DX**: Excellent developer experience with hot module replacement
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create astro@latest
|
||||||
|
cd my-astro-project
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Astro is perfect for content-focused websites like blogs.
|
||||||
|
excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs."
|
||||||
|
category: "tech"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- astro
|
||||||
|
- web-dev
|
||||||
|
- static-site
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
pid: 4
|
||||||
|
title: "Terminal UI Design Principles"
|
||||||
|
slug: "terminal-ui-design"
|
||||||
|
content: |
|
||||||
|
# Terminal UI Design Principles
|
||||||
|
|
||||||
|
Terminal-style interfaces are making a comeback in modern web design.
|
||||||
|
|
||||||
|
## Key Elements
|
||||||
|
|
||||||
|
1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono
|
||||||
|
2. **Dark Themes**: Black or dark backgrounds with vibrant text colors
|
||||||
|
3. **Command Prompts**: Use `$` or `>` as visual indicators
|
||||||
|
4. **ASCII Art**: Decorative elements using text characters
|
||||||
|
5. **Blinking Cursor**: The iconic terminal cursor
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
- Background: `#0d1117`
|
||||||
|
- Text: `#c9d1d9`
|
||||||
|
- Accent: `#58a6ff`
|
||||||
|
- Success: `#3fb950`
|
||||||
|
- Warning: `#d29922`
|
||||||
|
- Error: `#f85149`
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
||||||
|
excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces."
|
||||||
|
category: "design"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- design
|
||||||
|
- terminal
|
||||||
|
- ui
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
pid: 5
|
||||||
|
title: "Loco.rs Backend Framework"
|
||||||
|
slug: "loco-rs-framework"
|
||||||
|
content: |
|
||||||
|
# Introduction to Loco.rs
|
||||||
|
|
||||||
|
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **MVC Architecture**: Model-View-Controller pattern
|
||||||
|
- **SeaORM Integration**: Powerful ORM for database operations
|
||||||
|
- **Background Jobs**: Built-in job processing
|
||||||
|
- **Authentication**: Ready-to-use auth system
|
||||||
|
- **CLI Generator**: Scaffold resources quickly
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install loco
|
||||||
|
loco new myapp
|
||||||
|
cd myapp
|
||||||
|
cargo loco start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Loco.rs?
|
||||||
|
|
||||||
|
- Opinionated but flexible
|
||||||
|
- Production-ready defaults
|
||||||
|
- Excellent documentation
|
||||||
|
- Active community
|
||||||
|
|
||||||
|
Perfect for building APIs and web applications in Rust.
|
||||||
|
excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust."
|
||||||
|
category: "tech"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- rust
|
||||||
|
- loco-rs
|
||||||
|
- backend
|
||||||
|
- api
|
||||||
59
backend/assets/seeds/reviews.yaml
Normal file
59
backend/assets/seeds/reviews.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
- id: 1
|
||||||
|
title: "塞尔达传说:王国之泪"
|
||||||
|
review_type: "game"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-03-20"
|
||||||
|
status: "completed"
|
||||||
|
description: "开放世界的巅峰之作,究极手能力带来无限创意空间"
|
||||||
|
tags: ["Switch", "开放世界", "冒险"]
|
||||||
|
cover: "🎮"
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
title: "进击的巨人"
|
||||||
|
review_type: "anime"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2023-11-10"
|
||||||
|
status: "completed"
|
||||||
|
description: "史诗级完结,剧情反转令人震撼"
|
||||||
|
tags: ["热血", "悬疑", "神作"]
|
||||||
|
cover: "🎭"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
title: "赛博朋克 2077"
|
||||||
|
review_type: "game"
|
||||||
|
rating: 4
|
||||||
|
review_date: "2024-01-15"
|
||||||
|
status: "completed"
|
||||||
|
description: "夜之城的故事,虽然首发有问题但后续更新很棒"
|
||||||
|
tags: ["PC", "RPG", "科幻"]
|
||||||
|
cover: "🎮"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
title: "三体"
|
||||||
|
review_type: "book"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2023-08-05"
|
||||||
|
status: "completed"
|
||||||
|
description: "硬科幻巅峰,宇宙社会学的黑暗森林法则"
|
||||||
|
tags: ["科幻", "经典", "雨果奖"]
|
||||||
|
cover: "📚"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
title: "星际穿越"
|
||||||
|
review_type: "movie"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-02-14"
|
||||||
|
status: "completed"
|
||||||
|
description: "诺兰神作,五维空间和黑洞的视觉奇观"
|
||||||
|
tags: ["科幻", "IMAX", "诺兰"]
|
||||||
|
cover: "🎬"
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
title: "博德之门3"
|
||||||
|
review_type: "game"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-04-01"
|
||||||
|
status: "in-progress"
|
||||||
|
description: "CRPG的文艺复兴,骰子决定命运"
|
||||||
|
tags: ["PC", "CRPG", "多人"]
|
||||||
|
cover: "🎮"
|
||||||
39
backend/assets/seeds/tags.yaml
Normal file
39
backend/assets/seeds/tags.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
- id: 1
|
||||||
|
name: "Welcome"
|
||||||
|
slug: "welcome"
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
name: "Astro"
|
||||||
|
slug: "astro"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
name: "Rust"
|
||||||
|
slug: "rust"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
name: "Programming"
|
||||||
|
slug: "programming"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
name: "Tech"
|
||||||
|
slug: "tech"
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
name: "Design"
|
||||||
|
slug: "design"
|
||||||
|
|
||||||
|
- id: 7
|
||||||
|
name: "Terminal"
|
||||||
|
slug: "terminal"
|
||||||
|
|
||||||
|
- id: 8
|
||||||
|
name: "Loco.rs"
|
||||||
|
slug: "loco-rs"
|
||||||
|
|
||||||
|
- id: 9
|
||||||
|
name: "Backend"
|
||||||
|
slug: "backend"
|
||||||
|
|
||||||
|
- id: 10
|
||||||
|
name: "Web Dev"
|
||||||
|
slug: "web-dev"
|
||||||
3
backend/assets/static/404.html
Normal file
3
backend/assets/static/404.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<html><body>
|
||||||
|
not found :-(
|
||||||
|
</body></html>
|
||||||
BIN
backend/assets/static/image.png
Normal file
BIN
backend/assets/static/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 298 KiB |
11
backend/assets/static/index.html
Normal file
11
backend/assets/static/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="0; url=/admin">
|
||||||
|
<title>Redirecting...</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Redirecting to <a href="/admin">Admin Dashboard</a>...</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
682
backend/assets/views/admin/base.html
Normal file
682
backend/assets/views/admin/base.html
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ page_title | default(value="Termi Admin") }} · Termi Admin</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f4f5;
|
||||||
|
--bg-panel: rgba(255, 255, 255, 0.88);
|
||||||
|
--bg-panel-strong: rgba(255, 255, 255, 0.98);
|
||||||
|
--line: rgba(24, 24, 27, 0.09);
|
||||||
|
--line-strong: rgba(24, 24, 27, 0.16);
|
||||||
|
--text: #09090b;
|
||||||
|
--text-soft: #52525b;
|
||||||
|
--text-mute: #71717a;
|
||||||
|
--accent: #18181b;
|
||||||
|
--accent-2: #2563eb;
|
||||||
|
--accent-3: #dc2626;
|
||||||
|
--accent-4: #16a34a;
|
||||||
|
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius-xl: 24px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--font-sans: "Inter", "Segoe UI", "PingFang SC", sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Cascadia Code", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 30%),
|
||||||
|
linear-gradient(180deg, #fafafa 0%, #f4f4f5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 290px minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.surface,
|
||||||
|
.stat,
|
||||||
|
.table-panel,
|
||||||
|
.hero-card,
|
||||||
|
.form-panel,
|
||||||
|
.login-panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
padding: 28px 22px;
|
||||||
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #111827;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover,
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(24, 24, 27, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-kicker {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-kicker strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-kicker p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
padding: 26px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(24, 24, 27, 0.05);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
font-size: clamp(1.7rem, 2.2vw, 2.5rem);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-description {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 760px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 160ms ease;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fafafa;
|
||||||
|
box-shadow: 0 10px 24px rgba(24, 24, 27, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
border-color: rgba(220, 38, 38, 0.14);
|
||||||
|
color: var(--accent-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: rgba(22, 163, 74, 0.08);
|
||||||
|
border-color: rgba(22, 163, 74, 0.14);
|
||||||
|
color: var(--accent-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-warning {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border-color: rgba(245, 158, 11, 0.16);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid,
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat,
|
||||||
|
.hero-card,
|
||||||
|
.table-panel,
|
||||||
|
.form-panel {
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label,
|
||||||
|
.muted,
|
||||||
|
.table-note,
|
||||||
|
.field-hint,
|
||||||
|
.badge-soft {
|
||||||
|
color: var(--text-mute);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-blue .stat-value { color: var(--accent-2); }
|
||||||
|
.tone-gold .stat-value { color: var(--accent); }
|
||||||
|
.tone-green .stat-value { color: var(--accent-4); }
|
||||||
|
.tone-pink .stat-value { color: var(--accent-3); }
|
||||||
|
.tone-violet .stat-value { color: #7a5ef4; }
|
||||||
|
|
||||||
|
.table-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-head h2,
|
||||||
|
.hero-card h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 880px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid rgba(93, 76, 56, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(250, 250, 250, 0.98);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title strong {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta,
|
||||||
|
.mono {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge,
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
color: var(--accent-4);
|
||||||
|
background: rgba(93, 122, 45, 0.1);
|
||||||
|
border-color: rgba(93, 122, 45, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
color: var(--accent);
|
||||||
|
background: rgba(202, 94, 45, 0.1);
|
||||||
|
border-color: rgba(202, 94, 45, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
color: var(--accent-3);
|
||||||
|
background: rgba(156, 61, 84, 0.1);
|
||||||
|
border-color: rgba(156, 61, 84, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: rgba(241, 245, 249, 0.95);
|
||||||
|
color: var(--text-soft);
|
||||||
|
border-color: rgba(148, 163, 184, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-link {
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form.compact {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-grid textarea,
|
||||||
|
.compact-grid input,
|
||||||
|
.compact-grid select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.98);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-grid textarea {
|
||||||
|
min-height: 84px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: none;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-success {
|
||||||
|
color: var(--accent-4);
|
||||||
|
background: rgba(22, 163, 74, 0.08);
|
||||||
|
border-color: rgba(22, 163, 74, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-error {
|
||||||
|
color: var(--accent-3);
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
border-color: rgba(220, 38, 38, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
padding: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel h1 {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
display: none;
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.14);
|
||||||
|
color: var(--accent-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.shell,
|
||||||
|
.surface {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 760px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% block body %}
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div>
|
||||||
|
<div class="brand-mark">/></div>
|
||||||
|
<h1 class="brand-title">Termi Admin</h1>
|
||||||
|
<p class="brand-copy">后台数据直接联动前台页面。你可以在这里审核评论和友链、检查分类标签,并跳到对应前台页面确认效果。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-group">
|
||||||
|
<a href="/admin" class="nav-item {% if active_nav == 'dashboard' %}active{% endif %}">概览面板</a>
|
||||||
|
<a href="/admin/posts" class="nav-item {% if active_nav == 'posts' %}active{% endif %}">文章管理</a>
|
||||||
|
<a href="/admin/comments" class="nav-item {% if active_nav == 'comments' %}active{% endif %}">评论审核</a>
|
||||||
|
<a href="/admin/categories" class="nav-item {% if active_nav == 'categories' %}active{% endif %}">分类管理</a>
|
||||||
|
<a href="/admin/tags" class="nav-item {% if active_nav == 'tags' %}active{% endif %}">标签管理</a>
|
||||||
|
<a href="/admin/reviews" class="nav-item {% if active_nav == 'reviews' %}active{% endif %}">评价管理</a>
|
||||||
|
<a href="/admin/friend_links" class="nav-item {% if active_nav == 'friend_links' %}active{% endif %}">友链申请</a>
|
||||||
|
<a href="/admin/site-settings" class="nav-item {% if active_nav == 'site_settings' %}active{% endif %}">站点设置</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="nav-kicker">
|
||||||
|
<strong>前台联调入口</strong>
|
||||||
|
<p>所有管理页都带了前台直达链接,处理完数据后可以立刻跳转验证。</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="content-shell">
|
||||||
|
<header class="surface topbar">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Unified Admin</span>
|
||||||
|
<h1 class="page-title">{{ page_title | default(value="Termi Admin") }}</h1>
|
||||||
|
<p class="page-description">{{ page_description | default(value="统一处理后台数据与前台联调。") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
{% for item in header_actions | default(value=[]) %}
|
||||||
|
<a
|
||||||
|
href="{{ item.href }}"
|
||||||
|
class="btn btn-{{ item.variant }}"
|
||||||
|
{% if item.external %}target="_blank" rel="noreferrer noopener"{% endif %}
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
<a href="/admin/logout" class="btn btn-danger">退出后台</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content-grid">
|
||||||
|
{% block main_content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function adminPatch(url, payload, successMessage) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text() || "request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successMessage) {
|
||||||
|
alert(successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block page_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
85
backend/assets/views/admin/categories.html
Normal file
85
backend/assets/views/admin/categories.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>新增分类</h2>
|
||||||
|
<div class="table-note">这里维护分类字典。文章 Markdown 导入时会优先复用这里的分类,不存在才自动创建。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/categories" class="inline-form">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<input type="text" name="name" placeholder="分类名,例如 Technology" value="{{ create_form.name }}" required>
|
||||||
|
<input type="text" name="slug" placeholder="slug,可留空自动生成" value="{{ create_form.slug }}">
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">创建分类</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>分类列表</h2>
|
||||||
|
<div class="table-note">分类名称会作为文章展示名称使用,文章数来自当前已同步的真实内容。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows | length > 0 %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>文章数</th>
|
||||||
|
<th>最近文章</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/categories/{{ row.id }}/update" class="inline-form compact">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<input type="text" name="name" value="{{ row.name }}" required>
|
||||||
|
<input type="text" name="slug" value="{{ row.slug }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-success">保存</button>
|
||||||
|
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><span class="chip">{{ row.count }} 篇</span></td>
|
||||||
|
<td>
|
||||||
|
{% if row.latest_frontend_url %}
|
||||||
|
<a href="{{ row.latest_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.latest_title }}</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-soft">{{ row.latest_title }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台分类页</a>
|
||||||
|
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
|
||||||
|
<form method="post" action="/admin/categories/{{ row.id }}/delete">
|
||||||
|
<button type="submit" class="btn btn-danger">删除</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">暂无分类数据。</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
63
backend/assets/views/admin/comments.html
Normal file
63
backend/assets/views/admin/comments.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="table-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>评论队列</h2>
|
||||||
|
<div class="table-note">处理前台真实评论,并能一键跳到对应文章页核对展示。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows | length > 0 %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>作者 / 文章</th>
|
||||||
|
<th>内容</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="item-title">
|
||||||
|
<strong>{{ row.author }}</strong>
|
||||||
|
<span class="item-meta">{{ row.post_slug }}</span>
|
||||||
|
{% if row.frontend_url %}
|
||||||
|
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">跳到前台文章</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.content }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.approved %}
|
||||||
|
<span class="badge badge-success">已审核</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">待审核</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
|
||||||
|
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
|
||||||
|
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">暂无评论数据。</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
64
backend/assets/views/admin/friend_links.html
Normal file
64
backend/assets/views/admin/friend_links.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="table-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>友链审核</h2>
|
||||||
|
<div class="table-note">前台提交后会进入这里,你可以审核状态,再跳去前台友链页确认展示。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows | length > 0 %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>站点</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="item-title">
|
||||||
|
<strong>{{ row.site_name }}</strong>
|
||||||
|
<a href="{{ row.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ row.site_url }}</a>
|
||||||
|
<span class="item-meta">{{ row.description }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.category_name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if row.status == "已通过" %}
|
||||||
|
<span class="badge badge-success">{{ row.status }}</span>
|
||||||
|
{% elif row.status == "已拒绝" %}
|
||||||
|
<span class="badge badge-danger">{{ row.status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning">{{ row.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"status": "approved"}, "友链状态已更新")'>通过</button>
|
||||||
|
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"status": "pending"}, "友链状态已更新")'>待审</button>
|
||||||
|
<button class="btn btn-danger" onclick='adminPatch("{{ row.api_url }}", {"status": "rejected"}, "友链状态已更新")'>拒绝</button>
|
||||||
|
<a href="{{ row.frontend_page_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台友链页</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">暂无友链申请数据。</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
29
backend/assets/views/admin/index.html
Normal file
29
backend/assets/views/admin/index.html
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="stats-grid">
|
||||||
|
{% for stat in stats %}
|
||||||
|
<article class="stat tone-{{ stat.tone }}">
|
||||||
|
<div class="stat-label">{{ stat.label }}</div>
|
||||||
|
<div class="stat-value">{{ stat.value }}</div>
|
||||||
|
<div class="muted">{{ stat.note }}</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hero-card">
|
||||||
|
<h2>{{ site_profile.site_name }}</h2>
|
||||||
|
<p class="page-description" style="margin-bottom: 10px;">{{ site_profile.site_description }}</p>
|
||||||
|
<a href="{{ site_profile.site_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">{{ site_profile.site_url }}</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card-grid">
|
||||||
|
{% for card in nav_cards %}
|
||||||
|
<a href="{{ card.href }}" class="hero-card">
|
||||||
|
<h2>{{ card.title }}</h2>
|
||||||
|
<p class="page-description" style="margin-bottom: 10px;">{{ card.description }}</p>
|
||||||
|
<span class="chip">{{ card.meta }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
35
backend/assets/views/admin/login.html
Normal file
35
backend/assets/views/admin/login.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="login-shell">
|
||||||
|
<section class="login-panel">
|
||||||
|
<span class="eyebrow">Termi Admin</span>
|
||||||
|
<div class="brand-mark" style="margin-top: 18px;">/></div>
|
||||||
|
<h1>后台管理入口</h1>
|
||||||
|
<p>评论审核、友链申请、分类标签检查和站点设置都在这里统一处理。当前后台界面已经走 Tera 模板,不再在 Rust 里硬拼整页 HTML。</p>
|
||||||
|
|
||||||
|
<div class="login-error {% if show_error %}show{% endif %}">
|
||||||
|
用户名或密码错误,请重试。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/admin/login" class="form-grid" style="margin-top: 22px;">
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input name="username" placeholder="admin" required>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" name="password" placeholder="admin123" required>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: 100%;">进入后台</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="hero-card" style="margin-top: 18px;">
|
||||||
|
<h2>默认测试账号</h2>
|
||||||
|
<p class="mono">admin / admin123</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
70
backend/assets/views/admin/post_editor.html
Normal file
70
backend/assets/views/admin/post_editor.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{ editor.title }}</h2>
|
||||||
|
<div class="table-note">当前源文件:<span class="mono">{{ editor.file_path }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="markdown-editor-form" class="form-grid">
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>Slug</label>
|
||||||
|
<input value="{{ editor.slug }}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>Markdown 文件内容</label>
|
||||||
|
<textarea id="markdown-content" name="markdown" style="min-height: 65vh; font-family: var(--font-mono); line-height: 1.65;">{{ editor.markdown }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">保存 Markdown</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" style="margin-top: 10px;">这里保存的是服务器上的原始 Markdown 文件。你也可以直接在服务器用编辑器打开这个路径修改。</div>
|
||||||
|
<div id="notice" class="notice"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script>
|
||||||
|
const markdownForm = document.getElementById("markdown-editor-form");
|
||||||
|
const markdownField = document.getElementById("markdown-content");
|
||||||
|
const markdownNotice = document.getElementById("notice");
|
||||||
|
const markdownSlug = "{{ editor.slug }}";
|
||||||
|
|
||||||
|
function showMarkdownNotice(message, kind) {
|
||||||
|
markdownNotice.textContent = message;
|
||||||
|
markdownNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
markdownForm?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/posts/slug/${markdownSlug}/markdown`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
markdown: markdownField.value
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text() || "save failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
markdownField.value = payload.markdown;
|
||||||
|
showMarkdownNotice("Markdown 文件已保存并同步到数据库。", "success");
|
||||||
|
} catch (error) {
|
||||||
|
showMarkdownNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
199
backend/assets/views/admin/posts.html
Normal file
199
backend/assets/views/admin/posts.html
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>新建 Markdown 文章</h2>
|
||||||
|
<div class="table-note">直接生成 `content/posts/*.md` 文件,后端会自动解析 frontmatter、同步分类和标签。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/posts" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>标题</label>
|
||||||
|
<input type="text" name="title" value="{{ create_form.title }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Slug</label>
|
||||||
|
<input type="text" name="slug" value="{{ create_form.slug }}" placeholder="可留空自动生成">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>分类</label>
|
||||||
|
<input type="text" name="category" value="{{ create_form.category }}" placeholder="例如 tech">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>标签</label>
|
||||||
|
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="逗号分隔">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>文章类型</label>
|
||||||
|
<input type="text" name="post_type" value="{{ create_form.post_type }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>封面图</label>
|
||||||
|
<input type="text" name="image" value="{{ create_form.image }}" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>摘要</label>
|
||||||
|
<textarea name="description">{{ create_form.description }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>正文 Markdown</label>
|
||||||
|
<textarea name="content" style="min-height: 22rem; font-family: var(--font-mono); line-height: 1.65;">{{ create_form.content }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<div class="actions">
|
||||||
|
<label class="chip"><input type="checkbox" name="published" checked style="margin-right: 8px;">发布</label>
|
||||||
|
<label class="chip"><input type="checkbox" name="pinned" style="margin-right: 8px;">置顶</label>
|
||||||
|
<button type="submit" class="btn btn-primary">创建文章</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>导入 Markdown 文件</h2>
|
||||||
|
<div class="table-note">支持选择单个 `.md/.markdown` 文件,也支持直接选择一个本地 Markdown 文件夹批量导入。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="markdown-import-form" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>选择文件</label>
|
||||||
|
<input id="markdown-files" type="file" accept=".md,.markdown" multiple>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>选择文件夹</label>
|
||||||
|
<input id="markdown-folder" type="file" accept=".md,.markdown" webkitdirectory directory multiple>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<div class="actions">
|
||||||
|
<button id="import-submit" type="submit" class="btn btn-success">导入 Markdown</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" style="margin-top: 10px;">导入时会从 frontmatter 和正文里提取标题、slug、摘要、分类、标签与内容,并写入服务器 `content/posts`。</div>
|
||||||
|
<div id="import-notice" class="notice"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>内容列表</h2>
|
||||||
|
<div class="table-note">直接跳到前台文章、分类筛选和 API 明细。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows | length > 0 %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>文章</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>标签</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>跳转</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="item-title">
|
||||||
|
<strong>{{ row.title }}</strong>
|
||||||
|
<span class="item-meta">{{ row.slug }}</span>
|
||||||
|
<span class="item-meta">{{ row.file_path }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="item-title">
|
||||||
|
<strong>{{ row.category_name }}</strong>
|
||||||
|
<a href="{{ row.category_frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">查看该分类文章</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="inline-links">
|
||||||
|
{% if row.tags | length > 0 %}
|
||||||
|
{% for tag in row.tags %}
|
||||||
|
<span class="chip">#{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="badge-soft">暂无标签</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.created_at }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{ row.edit_url }}" class="btn btn-success">编辑 Markdown</a>
|
||||||
|
<a href="{{ row.frontend_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台详情</a>
|
||||||
|
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">当前没有可管理的文章数据。</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script>
|
||||||
|
const importForm = document.getElementById("markdown-import-form");
|
||||||
|
const importFiles = document.getElementById("markdown-files");
|
||||||
|
const importFolder = document.getElementById("markdown-folder");
|
||||||
|
const importNotice = document.getElementById("import-notice");
|
||||||
|
|
||||||
|
function showImportNotice(message, kind) {
|
||||||
|
importNotice.textContent = message;
|
||||||
|
importNotice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
importForm?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const selectedFiles = [
|
||||||
|
...(importFiles?.files ? Array.from(importFiles.files) : []),
|
||||||
|
...(importFolder?.files ? Array.from(importFolder.files) : []),
|
||||||
|
].filter((file) => file.name.endsWith(".md") || file.name.endsWith(".markdown"));
|
||||||
|
|
||||||
|
if (!selectedFiles.length) {
|
||||||
|
showImportNotice("请先选择要导入的 Markdown 文件或文件夹。", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = new FormData();
|
||||||
|
selectedFiles.forEach((file) => {
|
||||||
|
const uploadName = file.webkitRelativePath || file.name;
|
||||||
|
payload.append("files", file, uploadName);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/admin/posts/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text() || "import failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
showImportNotice(`已导入 ${result.count} 个 Markdown 文件,正在刷新列表。`, "success");
|
||||||
|
setTimeout(() => window.location.reload(), 900);
|
||||||
|
} catch (error) {
|
||||||
|
showImportNotice("导入失败:" + (error?.message || "unknown error"), "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
108
backend/assets/views/admin/reviews.html
Normal file
108
backend/assets/views/admin/reviews.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>新增评价</h2>
|
||||||
|
<div class="table-note">这里创建的评价会立刻出现在前台 `/reviews` 页面。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/reviews" class="inline-form">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<input type="text" name="title" placeholder="标题" value="{{ create_form.title }}" required>
|
||||||
|
<select name="review_type">
|
||||||
|
<option value="game" {% if create_form.review_type == "game" %}selected{% endif %}>游戏</option>
|
||||||
|
<option value="anime" {% if create_form.review_type == "anime" %}selected{% endif %}>动画</option>
|
||||||
|
<option value="music" {% if create_form.review_type == "music" %}selected{% endif %}>音乐</option>
|
||||||
|
<option value="book" {% if create_form.review_type == "book" %}selected{% endif %}>书籍</option>
|
||||||
|
<option value="movie" {% if create_form.review_type == "movie" %}selected{% endif %}>影视</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" name="rating" min="0" max="5" value="{{ create_form.rating }}" required>
|
||||||
|
<input type="date" name="review_date" value="{{ create_form.review_date }}">
|
||||||
|
<select name="status">
|
||||||
|
<option value="completed" {% if create_form.status == "completed" %}selected{% endif %}>已完成</option>
|
||||||
|
<option value="in-progress" {% if create_form.status == "in-progress" %}selected{% endif %}>进行中</option>
|
||||||
|
<option value="dropped" {% if create_form.status == "dropped" %}selected{% endif %}>已弃坑</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="cover" value="{{ create_form.cover }}" placeholder="封面图标或 emoji">
|
||||||
|
<input type="text" name="tags" value="{{ create_form.tags }}" placeholder="标签,逗号分隔">
|
||||||
|
<textarea name="description" placeholder="评价描述">{{ create_form.description }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">创建评价</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>评价列表</h2>
|
||||||
|
<div class="table-note">这里的每一行都可以直接编辑,保存后前台评价页会读取最新数据。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows | length > 0 %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>评价内容</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/reviews/{{ row.id }}/update" class="inline-form compact">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<input type="text" name="title" value="{{ row.title }}" required>
|
||||||
|
<select name="review_type">
|
||||||
|
<option value="game" {% if row.review_type == "game" %}selected{% endif %}>游戏</option>
|
||||||
|
<option value="anime" {% if row.review_type == "anime" %}selected{% endif %}>动画</option>
|
||||||
|
<option value="music" {% if row.review_type == "music" %}selected{% endif %}>音乐</option>
|
||||||
|
<option value="book" {% if row.review_type == "book" %}selected{% endif %}>书籍</option>
|
||||||
|
<option value="movie" {% if row.review_type == "movie" %}selected{% endif %}>影视</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" name="rating" min="0" max="5" value="{{ row.rating }}" required>
|
||||||
|
<input type="date" name="review_date" value="{{ row.review_date }}">
|
||||||
|
<select name="status">
|
||||||
|
<option value="completed" {% if row.status == "completed" %}selected{% endif %}>已完成</option>
|
||||||
|
<option value="in-progress" {% if row.status == "in-progress" %}selected{% endif %}>进行中</option>
|
||||||
|
<option value="dropped" {% if row.status == "dropped" %}selected{% endif %}>已弃坑</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="cover" value="{{ row.cover }}" placeholder="封面图标或 emoji">
|
||||||
|
<input type="text" name="tags" value="{{ row.tags_input }}" placeholder="标签,逗号分隔">
|
||||||
|
<textarea name="description" placeholder="评价描述">{{ row.description }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-success">保存</button>
|
||||||
|
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><span class="chip">{{ row.status }}</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="http://localhost:4321/reviews" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台查看</a>
|
||||||
|
<form method="post" action="/admin/reviews/{{ row.id }}/delete">
|
||||||
|
<button type="submit" class="btn btn-danger">删除</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">暂无评价数据。</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
143
backend/assets/views/admin/site_settings.html
Normal file
143
backend/assets/views/admin/site_settings.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>站点资料</h2>
|
||||||
|
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="site-settings-form" class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>站点名称</label>
|
||||||
|
<input name="site_name" value="{{ form.site_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>短名称</label>
|
||||||
|
<input name="site_short_name" value="{{ form.site_short_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>站点链接</label>
|
||||||
|
<input name="site_url" value="{{ form.site_url }}">
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>站点标题</label>
|
||||||
|
<input name="site_title" value="{{ form.site_title }}">
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>站点简介</label>
|
||||||
|
<textarea name="site_description">{{ form.site_description }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>首页主标题</label>
|
||||||
|
<input name="hero_title" value="{{ form.hero_title }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>首页副标题</label>
|
||||||
|
<input name="hero_subtitle" value="{{ form.hero_subtitle }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>个人名称</label>
|
||||||
|
<input name="owner_name" value="{{ form.owner_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>个人头衔</label>
|
||||||
|
<input name="owner_title" value="{{ form.owner_title }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>头像 URL</label>
|
||||||
|
<input name="owner_avatar_url" value="{{ form.owner_avatar_url }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>所在地</label>
|
||||||
|
<input name="location" value="{{ form.location }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>GitHub</label>
|
||||||
|
<input name="social_github" value="{{ form.social_github }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Twitter / X</label>
|
||||||
|
<input name="social_twitter" value="{{ form.social_twitter }}">
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>Email / mailto</label>
|
||||||
|
<input name="social_email" value="{{ form.social_email }}">
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>个人简介</label>
|
||||||
|
<textarea name="owner_bio">{{ form.owner_bio }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>技术栈(每行一个)</label>
|
||||||
|
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" style="margin-top: 10px;">保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。</div>
|
||||||
|
<div id="notice" class="notice"></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_scripts %}
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById("site-settings-form");
|
||||||
|
const notice = document.getElementById("notice");
|
||||||
|
|
||||||
|
function showNotice(message, kind) {
|
||||||
|
notice.textContent = message;
|
||||||
|
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||||
|
}
|
||||||
|
|
||||||
|
form?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const data = new FormData(form);
|
||||||
|
const payload = {
|
||||||
|
siteName: data.get("site_name"),
|
||||||
|
siteShortName: data.get("site_short_name"),
|
||||||
|
siteUrl: data.get("site_url"),
|
||||||
|
siteTitle: data.get("site_title"),
|
||||||
|
siteDescription: data.get("site_description"),
|
||||||
|
heroTitle: data.get("hero_title"),
|
||||||
|
heroSubtitle: data.get("hero_subtitle"),
|
||||||
|
ownerName: data.get("owner_name"),
|
||||||
|
ownerTitle: data.get("owner_title"),
|
||||||
|
ownerAvatarUrl: data.get("owner_avatar_url"),
|
||||||
|
location: data.get("location"),
|
||||||
|
socialGithub: data.get("social_github"),
|
||||||
|
socialTwitter: data.get("social_twitter"),
|
||||||
|
socialEmail: data.get("social_email"),
|
||||||
|
ownerBio: data.get("owner_bio"),
|
||||||
|
techStack: String(data.get("tech_stack") || "")
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/site_settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text() || "save failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotice("站点信息已保存。", "success");
|
||||||
|
} catch (error) {
|
||||||
|
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
77
backend/assets/views/admin/tags.html
Normal file
77
backend/assets/views/admin/tags.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>新增标签</h2>
|
||||||
|
<div class="table-note">这里维护标签字典。文章 Markdown 导入时会优先复用这里的标签,不存在才自动创建。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/tags" class="inline-form">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<input type="text" name="name" placeholder="标签名,例如 Rust" value="{{ create_form.name }}" required>
|
||||||
|
<input type="text" name="slug" placeholder="slug,可留空自动生成" value="{{ create_form.slug }}">
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">创建标签</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="table-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>标签映射</h2>
|
||||||
|
<div class="table-note">标签名称会作为文章展示名称使用,使用次数来自当前已同步的真实文章内容。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if rows | length > 0 %}
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>标签</th>
|
||||||
|
<th>使用次数</th>
|
||||||
|
<th>跳转</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">#{{ row.id }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/tags/{{ row.id }}/update" class="inline-form compact">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<input type="text" name="name" value="{{ row.name }}" required>
|
||||||
|
<input type="text" name="slug" value="{{ row.slug }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-success">保存</button>
|
||||||
|
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><span class="chip">{{ row.usage_count }} 篇文章</span></td>
|
||||||
|
<td>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{ row.frontend_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">前台标签页</a>
|
||||||
|
<a href="{{ row.articles_url }}" class="btn btn-primary" target="_blank" rel="noreferrer noopener">前台筛选</a>
|
||||||
|
<form method="post" action="/admin/tags/{{ row.id }}/delete">
|
||||||
|
<button type="submit" class="btn btn-danger">删除</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">暂无标签数据。</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
12
backend/assets/views/home/hello.html
Normal file
12
backend/assets/views/home/hello.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<html><body>
|
||||||
|
<img src="/static/image.png" width="200"/>
|
||||||
|
<br/>
|
||||||
|
find this tera template at <code>assets/views/home/hello.html</code>:
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
{{ t(key="hello-world", lang="en-US") }},
|
||||||
|
<br/>
|
||||||
|
{{ t(key="hello-world", lang="de-DE") }}
|
||||||
|
|
||||||
|
</body></html>
|
||||||
|
|
||||||
108
backend/config/development.yaml
Normal file
108
backend/config/development.yaml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Loco configuration file documentation
|
||||||
|
|
||||||
|
# Application logging configuration
|
||||||
|
logger:
|
||||||
|
# Enable or disable logging.
|
||||||
|
enable: true
|
||||||
|
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||||
|
pretty_backtrace: true
|
||||||
|
# Log level, options: trace, debug, info, warn or error.
|
||||||
|
level: debug
|
||||||
|
# Define the logging format. options: compact, pretty or json
|
||||||
|
format: compact
|
||||||
|
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||||
|
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||||
|
# override_filter: trace
|
||||||
|
|
||||||
|
# Web server configuration
|
||||||
|
server:
|
||||||
|
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||||
|
port: 5150
|
||||||
|
# Binding for the server (which interface to bind to)
|
||||||
|
binding: localhost
|
||||||
|
# The UI hostname or IP address that mailers will point to.
|
||||||
|
host: http://localhost
|
||||||
|
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||||
|
middlewares:
|
||||||
|
static:
|
||||||
|
enable: true
|
||||||
|
must_exist: true
|
||||||
|
precompressed: false
|
||||||
|
folder:
|
||||||
|
uri: "/static"
|
||||||
|
path: "assets/static"
|
||||||
|
# fallback to index.html which redirects to /admin
|
||||||
|
fallback: "assets/static/index.html"
|
||||||
|
|
||||||
|
# Worker Configuration
|
||||||
|
workers:
|
||||||
|
# specifies the worker mode. Options:
|
||||||
|
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||||
|
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||||
|
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||||
|
mode: BackgroundQueue
|
||||||
|
|
||||||
|
|
||||||
|
# Queue Configuration
|
||||||
|
queue:
|
||||||
|
kind: Redis
|
||||||
|
# Redis connection URI
|
||||||
|
uri: {{ get_env(name="REDIS_URL", default="redis://127.0.0.1") }}
|
||||||
|
# Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_flush: false
|
||||||
|
|
||||||
|
# Mailer Configuration.
|
||||||
|
mailer:
|
||||||
|
# SMTP mailer configuration.
|
||||||
|
smtp:
|
||||||
|
# Enable/Disable smtp mailer.
|
||||||
|
enable: false
|
||||||
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
|
host: localhost
|
||||||
|
# SMTP server port
|
||||||
|
port: 1025
|
||||||
|
# Use secure connection (SSL/TLS).
|
||||||
|
secure: false
|
||||||
|
# auth:
|
||||||
|
# user:
|
||||||
|
# password:
|
||||||
|
# Override the SMTP hello name (default is the machine's hostname)
|
||||||
|
# hello_name:
|
||||||
|
|
||||||
|
# Initializers Configuration
|
||||||
|
# initializers:
|
||||||
|
# oauth2:
|
||||||
|
# authorization_code: # Authorization code grant type
|
||||||
|
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||||
|
# ... other fields
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
database:
|
||||||
|
# Database connection URI - Set DATABASE_URL env var for proper password handling
|
||||||
|
# Example: set DATABASE_URL=postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development
|
||||||
|
uri: {{ get_env(name="DATABASE_URL") }}
|
||||||
|
# When enabled, the sql query will be logged.
|
||||||
|
enable_logging: false
|
||||||
|
# Set the timeout duration when acquiring a connection.
|
||||||
|
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||||
|
# Set the idle duration before closing a connection.
|
||||||
|
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||||
|
# Minimum number of connections for a pool.
|
||||||
|
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||||
|
# Maximum number of connections for a pool.
|
||||||
|
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
|
||||||
|
# Run migration up when application loaded
|
||||||
|
auto_migrate: true
|
||||||
|
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_truncate: false
|
||||||
|
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_recreate: false
|
||||||
|
|
||||||
|
# Authentication Configuration
|
||||||
|
auth:
|
||||||
|
# JWT authentication
|
||||||
|
jwt:
|
||||||
|
# Secret key for token generation and verification
|
||||||
|
secret: BkiGyoCGICNNg07cLWyS
|
||||||
|
# Token expiration time in seconds
|
||||||
|
expiration: 604800 # 7 days
|
||||||
103
backend/config/test.yaml
Normal file
103
backend/config/test.yaml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Loco configuration file documentation
|
||||||
|
|
||||||
|
# Application logging configuration
|
||||||
|
logger:
|
||||||
|
# Enable or disable logging.
|
||||||
|
enable: false
|
||||||
|
# Enable pretty backtrace (sets RUST_BACKTRACE=1)
|
||||||
|
pretty_backtrace: true
|
||||||
|
# Log level, options: trace, debug, info, warn or error.
|
||||||
|
level: debug
|
||||||
|
# Define the logging format. options: compact, pretty or json
|
||||||
|
format: compact
|
||||||
|
# By default the logger has filtering only logs that came from your code or logs that came from `loco` framework. to see all third party libraries
|
||||||
|
# Uncomment the line below to override to see all third party libraries you can enable this config and override the logger filters.
|
||||||
|
# override_filter: trace
|
||||||
|
|
||||||
|
# Web server configuration
|
||||||
|
server:
|
||||||
|
# Port on which the server will listen. the server binding is 0.0.0.0:{PORT}
|
||||||
|
port: 5150
|
||||||
|
# The UI hostname or IP address that mailers will point to.
|
||||||
|
host: http://localhost
|
||||||
|
# Out of the box middleware configuration. to disable middleware you can changed the `enable` field to `false` of comment the middleware block
|
||||||
|
middlewares:
|
||||||
|
static:
|
||||||
|
enable: true
|
||||||
|
must_exist: true
|
||||||
|
precompressed: false
|
||||||
|
folder:
|
||||||
|
uri: "/static"
|
||||||
|
path: "assets/static"
|
||||||
|
fallback: "assets/static/404.html"
|
||||||
|
|
||||||
|
# Worker Configuration
|
||||||
|
workers:
|
||||||
|
# specifies the worker mode. Options:
|
||||||
|
# - BackgroundQueue - Workers operate asynchronously in the background, processing queued.
|
||||||
|
# - ForegroundBlocking - Workers operate in the foreground and block until tasks are completed.
|
||||||
|
# - BackgroundAsync - Workers operate asynchronously in the background, processing tasks with async capabilities.
|
||||||
|
mode: ForegroundBlocking
|
||||||
|
|
||||||
|
|
||||||
|
# Queue Configuration
|
||||||
|
queue:
|
||||||
|
kind: Redis
|
||||||
|
# Redis connection URI
|
||||||
|
uri: {{ get_env(name="REDIS_URL", default="redis://127.0.0.1") }}
|
||||||
|
# Dangerously flush all data in Redis on startup. dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_flush: false
|
||||||
|
|
||||||
|
# Mailer Configuration.
|
||||||
|
mailer:
|
||||||
|
stub: true
|
||||||
|
# SMTP mailer configuration.
|
||||||
|
smtp:
|
||||||
|
# Enable/Disable smtp mailer.
|
||||||
|
enable: true
|
||||||
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
|
host: localhost
|
||||||
|
# SMTP server port
|
||||||
|
port: 1025
|
||||||
|
# Use secure connection (SSL/TLS).
|
||||||
|
secure: false
|
||||||
|
# auth:
|
||||||
|
# user:
|
||||||
|
# password:
|
||||||
|
|
||||||
|
# Initializers Configuration
|
||||||
|
# initializers:
|
||||||
|
# oauth2:
|
||||||
|
# authorization_code: # Authorization code grant type
|
||||||
|
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
||||||
|
# ... other fields
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
database:
|
||||||
|
# Database connection URI
|
||||||
|
uri: {{ get_env(name="DATABASE_URL", default="postgres://loco:loco@localhost:5432/termi-api_test") }}
|
||||||
|
# When enabled, the sql query will be logged.
|
||||||
|
enable_logging: false
|
||||||
|
# Set the timeout duration when acquiring a connection.
|
||||||
|
connect_timeout: {{ get_env(name="DB_CONNECT_TIMEOUT", default="500") }}
|
||||||
|
# Set the idle duration before closing a connection.
|
||||||
|
idle_timeout: {{ get_env(name="DB_IDLE_TIMEOUT", default="500") }}
|
||||||
|
# Minimum number of connections for a pool.
|
||||||
|
min_connections: {{ get_env(name="DB_MIN_CONNECTIONS", default="1") }}
|
||||||
|
# Maximum number of connections for a pool.
|
||||||
|
max_connections: {{ get_env(name="DB_MAX_CONNECTIONS", default="1") }}
|
||||||
|
# Run migration up when application loaded
|
||||||
|
auto_migrate: true
|
||||||
|
# Truncate database when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_truncate: true
|
||||||
|
# Recreating schema when application loaded. This is a dangerous operation, make sure that you using this flag only on dev environments or test mode
|
||||||
|
dangerously_recreate: true
|
||||||
|
|
||||||
|
# Authentication Configuration
|
||||||
|
auth:
|
||||||
|
# JWT authentication
|
||||||
|
jwt:
|
||||||
|
# Secret key for token generation and verification
|
||||||
|
secret: bm6m9nQtkBXdIvc8BPqj
|
||||||
|
# Token expiration time in seconds
|
||||||
|
expiration: 604800 # 7 days
|
||||||
37
backend/content/posts/building-blog-with-astro.md
Normal file
37
backend/content/posts/building-blog-with-astro.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Building a Blog with Astro
|
||||||
|
slug: building-blog-with-astro
|
||||||
|
description: Learn why Astro is the perfect choice for building fast, content-focused blogs.
|
||||||
|
category: tech
|
||||||
|
post_type: article
|
||||||
|
pinned: false
|
||||||
|
published: true
|
||||||
|
tags:
|
||||||
|
- astro
|
||||||
|
- web-dev
|
||||||
|
- static-site
|
||||||
|
---
|
||||||
|
|
||||||
|
# Building a Blog with Astro
|
||||||
|
|
||||||
|
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||||
|
|
||||||
|
## Why Astro?
|
||||||
|
|
||||||
|
- Zero JavaScript by default
|
||||||
|
- Island Architecture
|
||||||
|
- Framework Agnostic
|
||||||
|
- Great DX
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create astro@latest
|
||||||
|
cd my-astro-project
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Astro is perfect for content-focused websites like blogs.
|
||||||
44
backend/content/posts/loco-rs-framework.md
Normal file
44
backend/content/posts/loco-rs-framework.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
title: Loco.rs Backend Framework
|
||||||
|
slug: loco-rs-framework
|
||||||
|
description: An introduction to Loco.rs, the Rails-inspired web framework for Rust.
|
||||||
|
category: tech
|
||||||
|
post_type: article
|
||||||
|
pinned: false
|
||||||
|
published: true
|
||||||
|
tags:
|
||||||
|
- rust
|
||||||
|
- loco-rs
|
||||||
|
- backend
|
||||||
|
- api
|
||||||
|
---
|
||||||
|
|
||||||
|
# Introduction to Loco.rs
|
||||||
|
|
||||||
|
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- MVC Architecture
|
||||||
|
- SeaORM Integration
|
||||||
|
- Background Jobs
|
||||||
|
- Authentication
|
||||||
|
- CLI Generator
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install loco
|
||||||
|
loco new myapp
|
||||||
|
cd myapp
|
||||||
|
cargo loco start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Loco.rs?
|
||||||
|
|
||||||
|
- Opinionated but flexible
|
||||||
|
- Production-ready defaults
|
||||||
|
- Excellent documentation
|
||||||
|
- Active community
|
||||||
|
|
||||||
|
Perfect for building APIs and web applications in Rust.
|
||||||
38
backend/content/posts/rust-programming-tips.md
Normal file
38
backend/content/posts/rust-programming-tips.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Rust Programming Tips
|
||||||
|
slug: rust-programming-tips
|
||||||
|
description: Essential tips for Rust developers including ownership, pattern matching, and error handling.
|
||||||
|
category: tech
|
||||||
|
post_type: article
|
||||||
|
pinned: false
|
||||||
|
published: true
|
||||||
|
tags:
|
||||||
|
- rust
|
||||||
|
- programming
|
||||||
|
- tips
|
||||||
|
---
|
||||||
|
|
||||||
|
# Rust Programming Tips
|
||||||
|
|
||||||
|
Here are some essential tips for Rust developers:
|
||||||
|
|
||||||
|
## 1. Ownership and Borrowing
|
||||||
|
|
||||||
|
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
||||||
|
|
||||||
|
## 2. Pattern Matching
|
||||||
|
|
||||||
|
Use `match` expressions for exhaustive pattern matching:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match result {
|
||||||
|
Ok(value) => println!("Success: {}", value),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Error Handling
|
||||||
|
|
||||||
|
Use `Result` and `Option` types effectively with the `?` operator.
|
||||||
|
|
||||||
|
Happy coding!
|
||||||
38
backend/content/posts/terminal-ui-design.md
Normal file
38
backend/content/posts/terminal-ui-design.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
title: Terminal UI Design Principles
|
||||||
|
slug: terminal-ui-design
|
||||||
|
description: Learn the key principles of designing beautiful terminal-style user interfaces.
|
||||||
|
category: design
|
||||||
|
post_type: article
|
||||||
|
pinned: false
|
||||||
|
published: true
|
||||||
|
tags:
|
||||||
|
- design
|
||||||
|
- terminal
|
||||||
|
- ui
|
||||||
|
---
|
||||||
|
|
||||||
|
# Terminal UI Design Principles
|
||||||
|
|
||||||
|
Terminal-style interfaces are making a comeback in modern web design.
|
||||||
|
|
||||||
|
## Key Elements
|
||||||
|
|
||||||
|
1. Monospace fonts
|
||||||
|
2. Dark themes
|
||||||
|
3. Command prompts
|
||||||
|
4. ASCII art
|
||||||
|
5. Blinking cursor
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
- Background: `#0d1117`
|
||||||
|
- Text: `#c9d1d9`
|
||||||
|
- Accent: `#58a6ff`
|
||||||
|
- Success: `#3fb950`
|
||||||
|
- Warning: `#d29922`
|
||||||
|
- Error: `#f85149`
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
||||||
35
backend/content/posts/welcome-to-termi.md
Normal file
35
backend/content/posts/welcome-to-termi.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: Welcome to Termi Blog
|
||||||
|
slug: welcome-to-termi
|
||||||
|
description: Welcome to our new blog built with Astro and Loco.rs backend.
|
||||||
|
category: general
|
||||||
|
post_type: article
|
||||||
|
pinned: true
|
||||||
|
published: true
|
||||||
|
tags:
|
||||||
|
- welcome
|
||||||
|
- astro
|
||||||
|
- loco-rs
|
||||||
|
---
|
||||||
|
|
||||||
|
# Welcome to Termi Blog
|
||||||
|
|
||||||
|
This is the first post on our new blog built with Astro and Loco.rs backend.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Fast performance with Astro
|
||||||
|
- Terminal-style UI design
|
||||||
|
- Comments system
|
||||||
|
- Friend links
|
||||||
|
- Tags and categories
|
||||||
|
|
||||||
|
## Code Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
println!("Hello, Termi!");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stay tuned for more posts!
|
||||||
21
backend/examples/playground.rs
Normal file
21
backend/examples/playground.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#[allow(unused_imports)]
|
||||||
|
use loco_rs::{cli::playground, prelude::*};
|
||||||
|
use termi_api::app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
let _ctx = playground::<App>().await?;
|
||||||
|
|
||||||
|
// let active_model: articles::ActiveModel = articles::ActiveModel {
|
||||||
|
// title: Set(Some("how to build apps in 3 steps".to_string())),
|
||||||
|
// content: Set(Some("use Loco: https://loco.rs".to_string())),
|
||||||
|
// ..Default::default()
|
||||||
|
// };
|
||||||
|
// active_model.insert(&ctx.db).await.unwrap();
|
||||||
|
|
||||||
|
// let res = articles::Entity::find().all(&ctx.db).await.unwrap();
|
||||||
|
// println!("{:?}", res);
|
||||||
|
println!("welcome to playground. edit me at `examples/playground.rs`");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
22
backend/migration/Cargo.toml
Normal file
22
backend/migration/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "migration"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "migration"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
loco-rs = { workspace = true }
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies.sea-orm-migration]
|
||||||
|
version = "1.1.0"
|
||||||
|
features = [
|
||||||
|
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||||
|
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||||
|
# e.g.
|
||||||
|
"runtime-tokio-rustls", # `ASYNC_RUNTIME` feature
|
||||||
|
]
|
||||||
36
backend/migration/src/lib.rs
Normal file
36
backend/migration/src/lib.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#![allow(elided_lifetimes_in_paths)]
|
||||||
|
#![allow(clippy::wildcard_imports)]
|
||||||
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
mod m20220101_000001_users;
|
||||||
|
|
||||||
|
mod m20260327_060643_posts;
|
||||||
|
mod m20260327_061007_comments;
|
||||||
|
mod m20260327_061008_tags;
|
||||||
|
mod m20260327_061234_friend_links;
|
||||||
|
mod m20260327_061300_reviews;
|
||||||
|
mod m20260328_000001_add_post_slug_to_comments;
|
||||||
|
mod m20260328_000002_create_site_settings;
|
||||||
|
mod m20260328_000003_add_site_url_to_site_settings;
|
||||||
|
mod m20260328_000004_add_posts_search_index;
|
||||||
|
mod m20260328_000005_categories;
|
||||||
|
pub struct Migrator;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigratorTrait for Migrator {
|
||||||
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
|
vec![
|
||||||
|
Box::new(m20220101_000001_users::Migration),
|
||||||
|
Box::new(m20260327_060643_posts::Migration),
|
||||||
|
Box::new(m20260327_061007_comments::Migration),
|
||||||
|
Box::new(m20260327_061008_tags::Migration),
|
||||||
|
Box::new(m20260327_061234_friend_links::Migration),
|
||||||
|
Box::new(m20260327_061300_reviews::Migration),
|
||||||
|
Box::new(m20260328_000001_add_post_slug_to_comments::Migration),
|
||||||
|
Box::new(m20260328_000002_create_site_settings::Migration),
|
||||||
|
Box::new(m20260328_000003_add_site_url_to_site_settings::Migration),
|
||||||
|
Box::new(m20260328_000004_add_posts_search_index::Migration),
|
||||||
|
Box::new(m20260328_000005_categories::Migration),
|
||||||
|
// inject-above (do not remove this comment)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/migration/src/m20220101_000001_users.rs
Normal file
41
backend/migration/src/m20220101_000001_users.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"users",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("pid", ColType::Uuid),
|
||||||
|
("email", ColType::StringUniq),
|
||||||
|
("password", ColType::String),
|
||||||
|
("api_key", ColType::StringUniq),
|
||||||
|
("name", ColType::String),
|
||||||
|
("reset_token", ColType::StringNull),
|
||||||
|
("reset_sent_at", ColType::TimestampWithTimeZoneNull),
|
||||||
|
("email_verification_token", ColType::StringNull),
|
||||||
|
(
|
||||||
|
"email_verification_sent_at",
|
||||||
|
ColType::TimestampWithTimeZoneNull,
|
||||||
|
),
|
||||||
|
("email_verified_at", ColType::TimestampWithTimeZoneNull),
|
||||||
|
("magic_link_token", ColType::StringNull),
|
||||||
|
("magic_link_expiration", ColType::TimestampWithTimeZoneNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "users").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/migration/src/m20260327_060643_posts.rs
Normal file
33
backend/migration/src/m20260327_060643_posts.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"posts",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("title", ColType::StringNull),
|
||||||
|
("slug", ColType::String),
|
||||||
|
("description", ColType::StringNull),
|
||||||
|
("content", ColType::TextNull),
|
||||||
|
("category", ColType::StringNull),
|
||||||
|
("tags", ColType::JsonBinaryNull),
|
||||||
|
("post_type", ColType::StringNull),
|
||||||
|
("image", ColType::StringNull),
|
||||||
|
("pinned", ColType::BooleanNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "posts").await
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/migration/src/m20260327_061007_comments.rs
Normal file
31
backend/migration/src/m20260327_061007_comments.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"comments",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("post_id", ColType::UuidNull),
|
||||||
|
("author", ColType::StringNull),
|
||||||
|
("email", ColType::StringNull),
|
||||||
|
("avatar", ColType::StringNull),
|
||||||
|
("content", ColType::TextNull),
|
||||||
|
("reply_to", ColType::UuidNull),
|
||||||
|
("approved", ColType::BooleanNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "comments").await
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/migration/src/m20260327_061008_tags.rs
Normal file
26
backend/migration/src/m20260327_061008_tags.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"tags",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("name", ColType::StringNull),
|
||||||
|
("slug", ColType::String),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "tags").await
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/migration/src/m20260327_061234_friend_links.rs
Normal file
30
backend/migration/src/m20260327_061234_friend_links.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"friend_links",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("site_name", ColType::StringNull),
|
||||||
|
("site_url", ColType::String),
|
||||||
|
("avatar_url", ColType::StringNull),
|
||||||
|
("description", ColType::StringNull),
|
||||||
|
("category", ColType::StringNull),
|
||||||
|
("status", ColType::StringNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "friend_links").await
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/migration/src/m20260327_061300_reviews.rs
Normal file
32
backend/migration/src/m20260327_061300_reviews.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"reviews",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("title", ColType::StringNull),
|
||||||
|
("review_type", ColType::StringNull),
|
||||||
|
("rating", ColType::IntegerNull),
|
||||||
|
("review_date", ColType::StringNull),
|
||||||
|
("status", ColType::StringNull),
|
||||||
|
("description", ColType::StringNull),
|
||||||
|
("tags", ColType::StringNull),
|
||||||
|
("cover", ColType::StringNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "reviews").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new("comments"))
|
||||||
|
.add_column_if_not_exists(
|
||||||
|
ColumnDef::new(Alias::new("post_slug")).string().null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new("comments"))
|
||||||
|
.drop_column(Alias::new("post_slug"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"site_settings",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("site_name", ColType::StringNull),
|
||||||
|
("site_short_name", ColType::StringNull),
|
||||||
|
("site_url", ColType::StringNull),
|
||||||
|
("site_title", ColType::StringNull),
|
||||||
|
("site_description", ColType::StringNull),
|
||||||
|
("hero_title", ColType::StringNull),
|
||||||
|
("hero_subtitle", ColType::StringNull),
|
||||||
|
("owner_name", ColType::StringNull),
|
||||||
|
("owner_title", ColType::StringNull),
|
||||||
|
("owner_bio", ColType::TextNull),
|
||||||
|
("owner_avatar_url", ColType::StringNull),
|
||||||
|
("social_github", ColType::StringNull),
|
||||||
|
("social_twitter", ColType::StringNull),
|
||||||
|
("social_email", ColType::StringNull),
|
||||||
|
("location", ColType::StringNull),
|
||||||
|
("tech_stack", ColType::JsonBinaryNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "site_settings").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if manager.has_column("site_settings", "site_url").await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new("site_settings"))
|
||||||
|
.add_column(ColumnDef::new(Alias::new("site_url")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if !manager.has_column("site_settings", "site_url").await? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new("site_settings"))
|
||||||
|
.drop_column(Alias::new("site_url"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
use sea_orm_migration::sea_orm::DbBackend;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if manager.get_database_backend() != DbBackend::Postgres {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_search_fts
|
||||||
|
ON posts
|
||||||
|
USING GIN (
|
||||||
|
(
|
||||||
|
setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(description, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(category, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(tags::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(content, '')), 'D')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
if manager.get_database_backend() != DbBackend::Postgres {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("DROP INDEX IF EXISTS idx_posts_search_fts;")
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/migration/src/m20260328_000005_categories.rs
Normal file
26
backend/migration/src/m20260328_000005_categories.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
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> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"categories",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("name", ColType::StringNull),
|
||||||
|
("slug", ColType::String),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "categories").await
|
||||||
|
}
|
||||||
|
}
|
||||||
368
backend/src/app.rs
Normal file
368
backend/src/app.rs
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{http::Method, Router as AxumRouter};
|
||||||
|
use loco_rs::{
|
||||||
|
app::{AppContext, Hooks, Initializer},
|
||||||
|
bgworker::{BackgroundWorker, Queue},
|
||||||
|
boot::{create_app, BootResult, StartMode},
|
||||||
|
config::Config,
|
||||||
|
controller::AppRoutes,
|
||||||
|
db::{self, truncate_table},
|
||||||
|
environment::Environment,
|
||||||
|
task::Tasks,
|
||||||
|
Result,
|
||||||
|
};
|
||||||
|
use migration::Migrator;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use std::path::Path;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use crate::{
|
||||||
|
controllers, initializers,
|
||||||
|
models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users},
|
||||||
|
tasks,
|
||||||
|
workers::downloader::DownloadWorker,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct App;
|
||||||
|
#[async_trait]
|
||||||
|
impl Hooks for App {
|
||||||
|
fn app_name() -> &'static str {
|
||||||
|
env!("CARGO_CRATE_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_version() -> String {
|
||||||
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
option_env!("BUILD_SHA")
|
||||||
|
.or(option_env!("GITHUB_SHA"))
|
||||||
|
.unwrap_or("dev")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn boot(
|
||||||
|
mode: StartMode,
|
||||||
|
environment: &Environment,
|
||||||
|
config: Config,
|
||||||
|
) -> Result<BootResult> {
|
||||||
|
create_app::<Self, Migrator>(mode, environment, config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
|
||||||
|
Ok(vec![
|
||||||
|
Box::new(initializers::content_sync::ContentSyncInitializer),
|
||||||
|
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||||
|
AppRoutes::with_default_routes() // controller routes below
|
||||||
|
.add_route(controllers::admin::routes())
|
||||||
|
.add_route(controllers::review::routes())
|
||||||
|
.add_route(controllers::category::routes())
|
||||||
|
.add_route(controllers::friend_link::routes())
|
||||||
|
.add_route(controllers::tag::routes())
|
||||||
|
.add_route(controllers::comment::routes())
|
||||||
|
.add_route(controllers::post::routes())
|
||||||
|
.add_route(controllers::search::routes())
|
||||||
|
.add_route(controllers::site_settings::routes())
|
||||||
|
.add_route(controllers::auth::routes())
|
||||||
|
}
|
||||||
|
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE])
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
Ok(router.layer(cors))
|
||||||
|
}
|
||||||
|
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
|
||||||
|
queue.register(DownloadWorker::build(ctx)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn register_tasks(tasks: &mut Tasks) {
|
||||||
|
// tasks-inject (do not remove)
|
||||||
|
}
|
||||||
|
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||||
|
truncate_table(&ctx.db, users::Entity).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
|
// Seed users - use loco's default seed which handles duplicates
|
||||||
|
let users_file = base.join("users.yaml");
|
||||||
|
if users_file.exists() {
|
||||||
|
if let Err(e) =
|
||||||
|
db::seed::<users::ActiveModel>(&ctx.db, &users_file.display().to_string()).await
|
||||||
|
{
|
||||||
|
tracing::warn!("Users seed skipped or failed: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed tags first (no foreign key dependencies) - use Unchanged to ignore conflicts
|
||||||
|
if let Ok(seed_data) = std::fs::read_to_string(base.join("tags.yaml")) {
|
||||||
|
let tags_data: Vec<serde_json::Value> =
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||||
|
for tag in tags_data {
|
||||||
|
let id = tag["id"].as_i64().unwrap_or(0) as i32;
|
||||||
|
let name = tag["name"].as_str().unwrap_or("").to_string();
|
||||||
|
let slug = tag["slug"].as_str().unwrap_or("").to_string();
|
||||||
|
|
||||||
|
let existing = tags::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
if existing.is_none() {
|
||||||
|
let new_tag = tags::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
name: Set(Some(name)),
|
||||||
|
slug: Set(slug),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = new_tag.insert(&ctx.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed posts
|
||||||
|
if let Ok(seed_data) = std::fs::read_to_string(base.join("posts.yaml")) {
|
||||||
|
let posts_data: Vec<serde_json::Value> =
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||||
|
for post in posts_data {
|
||||||
|
let pid = post["pid"].as_i64().unwrap_or(0) as i32;
|
||||||
|
let title = post["title"].as_str().unwrap_or("").to_string();
|
||||||
|
let slug = post["slug"].as_str().unwrap_or("").to_string();
|
||||||
|
let content = post["content"].as_str().unwrap_or("").to_string();
|
||||||
|
let excerpt = post["excerpt"].as_str().unwrap_or("").to_string();
|
||||||
|
let category = post["category"].as_str().unwrap_or("").to_string();
|
||||||
|
let pinned = post["pinned"].as_bool().unwrap_or(false);
|
||||||
|
let post_type = post["post_type"].as_str().unwrap_or("article").to_string();
|
||||||
|
let tags_vec = post["tags"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let tags_json = if tags_vec.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::json!(tags_vec))
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing = posts::Entity::find_by_id(pid).one(&ctx.db).await?;
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut post_model = existing
|
||||||
|
.map(|model| model.into_active_model())
|
||||||
|
.unwrap_or_else(|| posts::ActiveModel {
|
||||||
|
id: Set(pid),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
post_model.title = Set(Some(title));
|
||||||
|
post_model.slug = Set(slug);
|
||||||
|
post_model.content = Set(Some(content));
|
||||||
|
post_model.description = Set(Some(excerpt));
|
||||||
|
post_model.category = Set(Some(category));
|
||||||
|
post_model.tags = Set(tags_json);
|
||||||
|
post_model.pinned = Set(Some(pinned));
|
||||||
|
post_model.post_type = Set(Some(post_type));
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
let _ = post_model.update(&ctx.db).await;
|
||||||
|
} else {
|
||||||
|
let _ = post_model.insert(&ctx.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed comments
|
||||||
|
if let Ok(seed_data) = std::fs::read_to_string(base.join("comments.yaml")) {
|
||||||
|
let comments_data: Vec<serde_json::Value> =
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||||
|
for comment in comments_data {
|
||||||
|
let id = comment["id"].as_i64().unwrap_or(0) as i32;
|
||||||
|
let pid = comment["pid"].as_i64().unwrap_or(0) as i32;
|
||||||
|
let author = comment["author"].as_str().unwrap_or("").to_string();
|
||||||
|
let email = comment["email"].as_str().unwrap_or("").to_string();
|
||||||
|
let content_text = comment["content"].as_str().unwrap_or("").to_string();
|
||||||
|
let approved = comment["approved"].as_bool().unwrap_or(false);
|
||||||
|
let post_slug = posts::Entity::find_by_id(pid)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.map(|post| post.slug);
|
||||||
|
|
||||||
|
let existing = comments::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
let has_existing = existing.is_some();
|
||||||
|
let mut comment_model = existing
|
||||||
|
.map(|model| model.into_active_model())
|
||||||
|
.unwrap_or_else(|| comments::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
comment_model.author = Set(Some(author));
|
||||||
|
comment_model.email = Set(Some(email));
|
||||||
|
comment_model.content = Set(Some(content_text));
|
||||||
|
comment_model.approved = Set(Some(approved));
|
||||||
|
comment_model.post_slug = Set(post_slug);
|
||||||
|
|
||||||
|
if has_existing {
|
||||||
|
let _ = comment_model.update(&ctx.db).await;
|
||||||
|
} else {
|
||||||
|
let _ = comment_model.insert(&ctx.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed friend links
|
||||||
|
if let Ok(seed_data) = std::fs::read_to_string(base.join("friend_links.yaml")) {
|
||||||
|
let links_data: Vec<serde_json::Value> =
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||||
|
for link in links_data {
|
||||||
|
let site_name = link["site_name"].as_str().unwrap_or("").to_string();
|
||||||
|
let site_url = link["site_url"].as_str().unwrap_or("").to_string();
|
||||||
|
let avatar_url = link["avatar_url"].as_str().map(|s: &str| s.to_string());
|
||||||
|
let description = link["description"].as_str().unwrap_or("").to_string();
|
||||||
|
let category = link["category"].as_str().unwrap_or("").to_string();
|
||||||
|
let status = link["status"].as_str().unwrap_or("pending").to_string();
|
||||||
|
|
||||||
|
let existing = friend_links::Entity::find()
|
||||||
|
.filter(friend_links::Column::SiteUrl.eq(&site_url))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
if existing.is_none() {
|
||||||
|
let new_link = friend_links::ActiveModel {
|
||||||
|
site_name: Set(Some(site_name)),
|
||||||
|
site_url: Set(site_url),
|
||||||
|
avatar_url: Set(avatar_url),
|
||||||
|
description: Set(Some(description)),
|
||||||
|
category: Set(Some(category)),
|
||||||
|
status: Set(Some(status)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = new_link.insert(&ctx.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed site settings
|
||||||
|
if let Ok(seed_data) = std::fs::read_to_string(base.join("site_settings.yaml")) {
|
||||||
|
let settings_data: Vec<serde_json::Value> =
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||||
|
if let Some(settings) = settings_data.first() {
|
||||||
|
let existing = site_settings::Entity::find()
|
||||||
|
.order_by_asc(site_settings::Column::Id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if existing.is_none() {
|
||||||
|
let tech_stack = settings["tech_stack"]
|
||||||
|
.as_array()
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_str())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.filter(|items| !items.is_empty())
|
||||||
|
.map(|items| serde_json::json!(items));
|
||||||
|
|
||||||
|
let item = site_settings::ActiveModel {
|
||||||
|
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
|
||||||
|
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
|
||||||
|
site_short_name: Set(
|
||||||
|
settings["site_short_name"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
|
||||||
|
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
|
||||||
|
site_description: Set(
|
||||||
|
settings["site_description"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
|
||||||
|
hero_subtitle: Set(
|
||||||
|
settings["hero_subtitle"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
|
||||||
|
owner_title: Set(
|
||||||
|
settings["owner_title"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
|
||||||
|
owner_avatar_url: Set(
|
||||||
|
settings["owner_avatar_url"].as_str().and_then(|value| {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
social_github: Set(
|
||||||
|
settings["social_github"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
social_twitter: Set(
|
||||||
|
settings["social_twitter"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
social_email: Set(
|
||||||
|
settings["social_email"].as_str().map(ToString::to_string),
|
||||||
|
),
|
||||||
|
location: Set(settings["location"].as_str().map(ToString::to_string)),
|
||||||
|
tech_stack: Set(tech_stack),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = item.insert(&ctx.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed reviews
|
||||||
|
if let Ok(seed_data) = std::fs::read_to_string(base.join("reviews.yaml")) {
|
||||||
|
let reviews_data: Vec<serde_json::Value> =
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default();
|
||||||
|
for review in reviews_data {
|
||||||
|
let title = review["title"].as_str().unwrap_or("").to_string();
|
||||||
|
let review_type = review["review_type"].as_str().unwrap_or("").to_string();
|
||||||
|
let rating = review["rating"].as_i64().unwrap_or(0) as i32;
|
||||||
|
let review_date = review["review_date"].as_str().unwrap_or("").to_string();
|
||||||
|
let status = review["status"].as_str().unwrap_or("completed").to_string();
|
||||||
|
let description = review["description"].as_str().unwrap_or("").to_string();
|
||||||
|
let cover = review["cover"].as_str().unwrap_or("📝").to_string();
|
||||||
|
let tags_vec = review["tags"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let existing = reviews::Entity::find()
|
||||||
|
.filter(reviews::Column::Title.eq(&title))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
if existing.is_none() {
|
||||||
|
let new_review = reviews::ActiveModel {
|
||||||
|
title: Set(Some(title)),
|
||||||
|
review_type: Set(Some(review_type)),
|
||||||
|
rating: Set(Some(rating)),
|
||||||
|
review_date: Set(Some(review_date)),
|
||||||
|
status: Set(Some(status)),
|
||||||
|
description: Set(Some(description)),
|
||||||
|
cover: Set(Some(cover)),
|
||||||
|
tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let _ = new_review.insert(&ctx.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/bin/main.rs
Normal file
8
backend/src/bin/main.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use loco_rs::cli;
|
||||||
|
use migration::Migrator;
|
||||||
|
use termi_api::app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
cli::main::<App, Migrator>().await
|
||||||
|
}
|
||||||
8
backend/src/bin/tool.rs
Normal file
8
backend/src/bin/tool.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use loco_rs::cli;
|
||||||
|
use migration::Migrator;
|
||||||
|
use termi_api::app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
cli::main::<App, Migrator>().await
|
||||||
|
}
|
||||||
1231
backend/src/controllers/admin.rs
Normal file
1231
backend/src/controllers/admin.rs
Normal file
File diff suppressed because it is too large
Load Diff
273
backend/src/controllers/auth.rs
Normal file
273
backend/src/controllers/auth.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
use crate::{
|
||||||
|
mailers::auth::AuthMailer,
|
||||||
|
models::{
|
||||||
|
_entities::users,
|
||||||
|
users::{LoginParams, RegisterParams},
|
||||||
|
},
|
||||||
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
|
};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_allow_email_domain_re() -> &'static Regex {
|
||||||
|
EMAIL_DOMAIN_RE.get_or_init(|| {
|
||||||
|
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ForgotParams {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ResetParams {
|
||||||
|
pub token: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct MagicLinkParams {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ResendVerificationParams {
|
||||||
|
pub email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register function creates a new user with the given parameters and sends a
|
||||||
|
/// welcome email to the user
|
||||||
|
#[debug_handler]
|
||||||
|
async fn register(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<RegisterParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let res = users::Model::create_with_password(&ctx.db, ¶ms).await;
|
||||||
|
|
||||||
|
let user = match res {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::info!(
|
||||||
|
message = err.to_string(),
|
||||||
|
user_email = ¶ms.email,
|
||||||
|
"could not register user",
|
||||||
|
);
|
||||||
|
return format::json(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_email_verification_sent(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify register user. if the user not verified his email, he can't login to
|
||||||
|
/// the system.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
|
||||||
|
return unauthorized("invalid token");
|
||||||
|
};
|
||||||
|
|
||||||
|
if user.email_verified_at.is_some() {
|
||||||
|
tracing::info!(pid = user.pid.to_string(), "user already verified");
|
||||||
|
} else {
|
||||||
|
let active_model = user.into_active_model();
|
||||||
|
let user = active_model.verified(&ctx.db).await?;
|
||||||
|
tracing::info!(pid = user.pid.to_string(), "user verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In case the user forgot his password this endpoints generate a forgot token
|
||||||
|
/// and send email to the user. In case the email not found in our DB, we are
|
||||||
|
/// returning a valid request for for security reasons (not exposing users DB
|
||||||
|
/// list).
|
||||||
|
#[debug_handler]
|
||||||
|
async fn forgot(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<ForgotParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
return format::json(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_forgot_password_sent(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
AuthMailer::forgot_password(&ctx, &user).await?;
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// reset user password by the given parameters
|
||||||
|
#[debug_handler]
|
||||||
|
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, ¶ms.token).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
tracing::info!("reset token not found");
|
||||||
|
|
||||||
|
return format::json(());
|
||||||
|
};
|
||||||
|
user.into_active_model()
|
||||||
|
.reset_password(&ctx.db, ¶ms.password)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a user login and returns a token
|
||||||
|
#[debug_handler]
|
||||||
|
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
tracing::debug!(
|
||||||
|
email = params.email,
|
||||||
|
"login attempt with non-existent email"
|
||||||
|
);
|
||||||
|
return unauthorized("Invalid credentials!");
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid = user.verify_password(¶ms.password);
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
return unauthorized("unauthorized!");
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||||
|
|
||||||
|
let token = user
|
||||||
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
format::json(LoginResponse::new(&user, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||||
|
///
|
||||||
|
/// # Flow
|
||||||
|
/// 1. **Request a Magic Link**:
|
||||||
|
/// A registered user sends a POST request to `/magic-link` with their email.
|
||||||
|
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
|
||||||
|
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
|
||||||
|
///
|
||||||
|
/// 2. **Click the Magic Link**:
|
||||||
|
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
|
||||||
|
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
|
||||||
|
/// If invalid or expired, an unauthorized response is returned.
|
||||||
|
///
|
||||||
|
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
|
||||||
|
async fn magic_link(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<MagicLinkParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let email_regex = get_allow_email_domain_re();
|
||||||
|
if !email_regex.is_match(¶ms.email) {
|
||||||
|
tracing::debug!(
|
||||||
|
email = params.email,
|
||||||
|
"The provided email is invalid or does not match the allowed domains"
|
||||||
|
);
|
||||||
|
return bad_request("invalid request");
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
tracing::debug!(email = params.email, "user not found by email");
|
||||||
|
return format::empty_json();
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
|
||||||
|
AuthMailer::send_magic_link(&ctx, &user).await?;
|
||||||
|
|
||||||
|
format::empty_json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a magic link token and authenticates the user.
|
||||||
|
async fn magic_link_verify(
|
||||||
|
Path(token): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
|
||||||
|
// we don't want to expose our users email. if the email is invalid we still
|
||||||
|
// returning success to the caller
|
||||||
|
return unauthorized("unauthorized!");
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
|
||||||
|
|
||||||
|
let jwt_secret = ctx.config.get_jwt_config()?;
|
||||||
|
|
||||||
|
let token = user
|
||||||
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
format::json(LoginResponse::new(&user, &token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn resend_verification_email(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<ResendVerificationParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = users::Model::find_by_email(&ctx.db, ¶ms.email).await else {
|
||||||
|
tracing::info!(
|
||||||
|
email = params.email,
|
||||||
|
"User not found for resend verification"
|
||||||
|
);
|
||||||
|
return format::json(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if user.email_verified_at.is_some() {
|
||||||
|
tracing::info!(
|
||||||
|
pid = user.pid.to_string(),
|
||||||
|
"User already verified, skipping resend"
|
||||||
|
);
|
||||||
|
return format::json(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = user
|
||||||
|
.into_active_model()
|
||||||
|
.set_email_verification_sent(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||||
|
tracing::info!(pid = user.pid.to_string(), "Verification email re-sent");
|
||||||
|
|
||||||
|
format::json(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("/api/auth")
|
||||||
|
.add("/register", post(register))
|
||||||
|
.add("/verify/{token}", get(verify))
|
||||||
|
.add("/login", post(login))
|
||||||
|
.add("/forgot", post(forgot))
|
||||||
|
.add("/reset", post(reset))
|
||||||
|
.add("/current", get(current))
|
||||||
|
.add("/magic-link", post(magic_link))
|
||||||
|
.add("/magic-link/{token}", get(magic_link_verify))
|
||||||
|
.add("/resend-verification-mail", post(resend_verification_email))
|
||||||
|
}
|
||||||
166
backend/src/controllers/category.rs
Normal file
166
backend/src/controllers/category.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::{categories, posts};
|
||||||
|
use crate::services::content;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct CategorySummary {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slugify(value: &str) -> String {
|
||||||
|
let mut slug = String::new();
|
||||||
|
let mut last_was_dash = false;
|
||||||
|
|
||||||
|
for ch in value.trim().chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
slug.push(ch.to_ascii_lowercase());
|
||||||
|
last_was_dash = false;
|
||||||
|
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||||
|
slug.push('-');
|
||||||
|
last_was_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slug.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_name(params: &Params) -> Result<String> {
|
||||||
|
let name = params
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||||
|
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_slug(params: &Params, fallback: &str) -> String {
|
||||||
|
params
|
||||||
|
.slug
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.unwrap_or_else(|| slugify(fallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||||
|
let item = categories::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or(Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
let category_items = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Slug)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let post_items = posts::Entity::find().all(&ctx.db).await?;
|
||||||
|
|
||||||
|
let categories = category_items
|
||||||
|
.into_iter()
|
||||||
|
.map(|category| {
|
||||||
|
let name = category
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| category.slug.clone());
|
||||||
|
let count = post_items
|
||||||
|
.iter()
|
||||||
|
.filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str()))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
CategorySummary {
|
||||||
|
id: category.id,
|
||||||
|
name,
|
||||||
|
slug: category.slug,
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
format::json(categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||||
|
let name = normalized_name(¶ms)?;
|
||||||
|
let slug = normalized_slug(¶ms, &name);
|
||||||
|
|
||||||
|
let existing = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Slug.eq(&slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let item = if let Some(existing_category) = existing {
|
||||||
|
let mut model = existing_category.into_active_model();
|
||||||
|
model.name = Set(Some(name));
|
||||||
|
model.slug = Set(slug);
|
||||||
|
model.update(&ctx.db).await?
|
||||||
|
} else {
|
||||||
|
categories::ActiveModel {
|
||||||
|
name: Set(Some(name)),
|
||||||
|
slug: Set(slug),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let name = normalized_name(¶ms)?;
|
||||||
|
let slug = normalized_slug(¶ms, &name);
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
item.name = Set(Some(name));
|
||||||
|
item.slug = Set(slug);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("/api/categories")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("/{id}", get(get_one))
|
||||||
|
.add("/{id}", delete(remove))
|
||||||
|
.add("/{id}", put(update))
|
||||||
|
.add("/{id}", patch(update))
|
||||||
|
}
|
||||||
193
backend/src/controllers/comment.rs
Normal file
193
backend/src/controllers/comment.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::{
|
||||||
|
comments::{ActiveModel, Column, Entity, Model},
|
||||||
|
posts,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub post_id: Option<Uuid>,
|
||||||
|
pub post_slug: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub reply_to: Option<Uuid>,
|
||||||
|
pub approved: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
fn update(&self, item: &mut ActiveModel) {
|
||||||
|
if let Some(post_id) = self.post_id {
|
||||||
|
item.post_id = Set(Some(post_id));
|
||||||
|
}
|
||||||
|
if let Some(post_slug) = &self.post_slug {
|
||||||
|
item.post_slug = Set(Some(post_slug.clone()));
|
||||||
|
}
|
||||||
|
if let Some(author) = &self.author {
|
||||||
|
item.author = Set(Some(author.clone()));
|
||||||
|
}
|
||||||
|
if let Some(email) = &self.email {
|
||||||
|
item.email = Set(Some(email.clone()));
|
||||||
|
}
|
||||||
|
if let Some(avatar) = &self.avatar {
|
||||||
|
item.avatar = Set(Some(avatar.clone()));
|
||||||
|
}
|
||||||
|
if let Some(content) = &self.content {
|
||||||
|
item.content = Set(Some(content.clone()));
|
||||||
|
}
|
||||||
|
if let Some(reply_to) = self.reply_to {
|
||||||
|
item.reply_to = Set(Some(reply_to));
|
||||||
|
}
|
||||||
|
if let Some(approved) = self.approved {
|
||||||
|
item.approved = Set(Some(approved));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub post_id: Option<String>,
|
||||||
|
pub post_slug: Option<String>,
|
||||||
|
pub approved: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct CreateCommentRequest {
|
||||||
|
#[serde(default, alias = "postId")]
|
||||||
|
pub post_id: Option<String>,
|
||||||
|
#[serde(default, alias = "postSlug")]
|
||||||
|
pub post_slug: Option<String>,
|
||||||
|
#[serde(default, alias = "nickname")]
|
||||||
|
pub author: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub content: Option<String>,
|
||||||
|
#[serde(default, alias = "replyTo")]
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub approved: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_post_slug(ctx: &AppContext, raw: &str) -> Result<Option<String>> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(id) = trimmed.parse::<i32>() {
|
||||||
|
let post = posts::Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
return Ok(post.map(|item| item.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut db_query = Entity::find().order_by_asc(Column::CreatedAt);
|
||||||
|
|
||||||
|
let post_slug = if let Some(post_slug) = query.post_slug {
|
||||||
|
Some(post_slug)
|
||||||
|
} else if let Some(post_id) = query.post_id {
|
||||||
|
resolve_post_slug(&ctx, &post_id).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(post_slug) = post_slug {
|
||||||
|
db_query = db_query.filter(Column::PostSlug.eq(post_slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(approved) = query.approved {
|
||||||
|
db_query = db_query.filter(Column::Approved.eq(approved));
|
||||||
|
}
|
||||||
|
|
||||||
|
format::json(db_query.all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<CreateCommentRequest>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let post_slug = if let Some(post_slug) = params.post_slug.as_deref() {
|
||||||
|
Some(post_slug.to_string())
|
||||||
|
} else if let Some(post_id) = params.post_id.as_deref() {
|
||||||
|
resolve_post_slug(&ctx, post_id).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
item.post_id = Set(params
|
||||||
|
.post_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||||
|
item.post_slug = Set(post_slug);
|
||||||
|
item.author = Set(params.author);
|
||||||
|
item.email = Set(params.email);
|
||||||
|
item.avatar = Set(params.avatar);
|
||||||
|
item.content = Set(params.content);
|
||||||
|
item.reply_to = Set(params
|
||||||
|
.reply_to
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||||
|
item.approved = Set(Some(params.approved.unwrap_or(false)));
|
||||||
|
let item = item.insert(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/comments/")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("{id}", get(get_one))
|
||||||
|
.add("{id}", delete(remove))
|
||||||
|
.add("{id}", put(update))
|
||||||
|
.add("{id}", patch(update))
|
||||||
|
}
|
||||||
137
backend/src/controllers/friend_link.rs
Normal file
137
backend/src/controllers/friend_link.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::friend_links::{ActiveModel, Column, Entity, Model};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub site_url: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
fn update(&self, item: &mut ActiveModel) {
|
||||||
|
item.site_url = Set(self.site_url.clone());
|
||||||
|
if let Some(site_name) = &self.site_name {
|
||||||
|
item.site_name = Set(Some(site_name.clone()));
|
||||||
|
}
|
||||||
|
if let Some(avatar_url) = &self.avatar_url {
|
||||||
|
item.avatar_url = Set(Some(avatar_url.clone()));
|
||||||
|
}
|
||||||
|
if let Some(description) = &self.description {
|
||||||
|
item.description = Set(Some(description.clone()));
|
||||||
|
}
|
||||||
|
if let Some(category) = &self.category {
|
||||||
|
item.category = Set(Some(category.clone()));
|
||||||
|
}
|
||||||
|
if let Some(status) = &self.status {
|
||||||
|
item.status = Set(Some(status.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct CreateFriendLinkRequest {
|
||||||
|
#[serde(default, alias = "siteName")]
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
#[serde(alias = "siteUrl")]
|
||||||
|
pub site_url: String,
|
||||||
|
#[serde(default, alias = "avatarUrl")]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut db_query = Entity::find().order_by_desc(Column::CreatedAt);
|
||||||
|
|
||||||
|
if let Some(status) = query.status {
|
||||||
|
db_query = db_query.filter(Column::Status.eq(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(category) = query.category {
|
||||||
|
db_query = db_query.filter(Column::Category.eq(category));
|
||||||
|
}
|
||||||
|
|
||||||
|
format::json(db_query.all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<CreateFriendLinkRequest>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
item.site_name = Set(params.site_name);
|
||||||
|
item.site_url = Set(params.site_url);
|
||||||
|
item.avatar_url = Set(params.avatar_url);
|
||||||
|
item.description = Set(params.description);
|
||||||
|
item.category = Set(params.category);
|
||||||
|
item.status = Set(Some(params.status.unwrap_or_else(|| "pending".to_string())));
|
||||||
|
let item = item.insert(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/friend_links/")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("{id}", get(get_one))
|
||||||
|
.add("{id}", delete(remove))
|
||||||
|
.add("{id}", put(update))
|
||||||
|
.add("{id}", patch(update))
|
||||||
|
}
|
||||||
10
backend/src/controllers/mod.rs
Normal file
10
backend/src/controllers/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod auth;
|
||||||
|
pub mod category;
|
||||||
|
pub mod comment;
|
||||||
|
pub mod friend_link;
|
||||||
|
pub mod post;
|
||||||
|
pub mod review;
|
||||||
|
pub mod search;
|
||||||
|
pub mod site_settings;
|
||||||
|
pub mod tag;
|
||||||
263
backend/src/controllers/post.rs
Normal file
263
backend/src/controllers/post.rs
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::QueryOrder;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model};
|
||||||
|
use crate::services::content;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub tags: Option<serde_json::Value>,
|
||||||
|
pub post_type: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub pinned: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
fn update(&self, item: &mut ActiveModel) {
|
||||||
|
item.title = Set(self.title.clone());
|
||||||
|
item.slug = Set(self.slug.clone());
|
||||||
|
item.description = Set(self.description.clone());
|
||||||
|
item.content = Set(self.content.clone());
|
||||||
|
item.category = Set(self.category.clone());
|
||||||
|
item.tags = Set(self.tags.clone());
|
||||||
|
item.post_type = Set(self.post_type.clone());
|
||||||
|
item.image = Set(self.image.clone());
|
||||||
|
item.pinned = Set(self.pinned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct ListQuery {
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub tag: Option<String>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
#[serde(alias = "type")]
|
||||||
|
pub post_type: Option<String>,
|
||||||
|
pub pinned: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct MarkdownUpdateParams {
|
||||||
|
pub markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct MarkdownDocumentResponse {
|
||||||
|
pub slug: String,
|
||||||
|
pub path: String,
|
||||||
|
pub markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
|
||||||
|
let item = Entity::find()
|
||||||
|
.filter(Column::Slug.eq(slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
|
||||||
|
let wanted = wanted_tag.trim().to_lowercase();
|
||||||
|
|
||||||
|
post.tags
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|tags| {
|
||||||
|
tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
|
||||||
|
let normalized = tag.trim().to_lowercase();
|
||||||
|
normalized == wanted
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
let posts = Entity::find()
|
||||||
|
.order_by_desc(Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let filtered: Vec<Model> = posts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| {
|
||||||
|
if let Some(slug) = &query.slug {
|
||||||
|
if post.slug != *slug {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(category) = &query.category {
|
||||||
|
if post
|
||||||
|
.category
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| !value.eq_ignore_ascii_case(category))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(post_type) = &query.post_type {
|
||||||
|
if post
|
||||||
|
.post_type
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| !value.eq_ignore_ascii_case(post_type))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pinned) = query.pinned {
|
||||||
|
if post.pinned.unwrap_or(false) != pinned {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(tag) = &query.tag {
|
||||||
|
if !post_has_tag(post, tag) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(search) = &query.search {
|
||||||
|
let wanted = search.trim().to_lowercase();
|
||||||
|
let haystack = [
|
||||||
|
post.title.as_deref().unwrap_or_default(),
|
||||||
|
post.description.as_deref().unwrap_or_default(),
|
||||||
|
post.content.as_deref().unwrap_or_default(),
|
||||||
|
post.category.as_deref().unwrap_or_default(),
|
||||||
|
&post.slug,
|
||||||
|
]
|
||||||
|
.join("\n")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
if !haystack.contains(&wanted)
|
||||||
|
&& !post
|
||||||
|
.tags
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.as_array())
|
||||||
|
.map(|tags| {
|
||||||
|
tags.iter()
|
||||||
|
.filter_map(|tag| tag.as_str())
|
||||||
|
.any(|tag| tag.to_lowercase().contains(&wanted))
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format::json(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.insert(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_by_slug(
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
format::json(load_item_by_slug(&ctx, &slug).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_markdown_by_slug(
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
let (path, markdown) = content::read_markdown_document(&slug)?;
|
||||||
|
format::json(MarkdownDocumentResponse { slug, path, markdown })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update_markdown_by_slug(
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<MarkdownUpdateParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let updated = content::write_markdown_document(&ctx, &slug, ¶ms.markdown).await?;
|
||||||
|
let (path, markdown) = content::read_markdown_document(&updated.slug)?;
|
||||||
|
|
||||||
|
format::json(MarkdownDocumentResponse {
|
||||||
|
slug: updated.slug,
|
||||||
|
path,
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/posts/")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("slug/{slug}/markdown", get(get_markdown_by_slug))
|
||||||
|
.add("slug/{slug}/markdown", put(update_markdown_by_slug))
|
||||||
|
.add("slug/{slug}/markdown", patch(update_markdown_by_slug))
|
||||||
|
.add("slug/{slug}", get(get_by_slug))
|
||||||
|
.add("{id}", get(get_one))
|
||||||
|
.add("{id}", delete(remove))
|
||||||
|
.add("{id}", put(update))
|
||||||
|
.add("{id}", patch(update))
|
||||||
|
}
|
||||||
133
backend/src/controllers/review.rs
Normal file
133
backend/src/controllers/review.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use axum::extract::{Path, State};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{EntityTrait, QueryOrder, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::reviews::{self, Entity as ReviewEntity};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct CreateReviewRequest {
|
||||||
|
pub title: String,
|
||||||
|
pub review_type: String,
|
||||||
|
pub rating: i32,
|
||||||
|
pub review_date: String,
|
||||||
|
pub status: String,
|
||||||
|
pub description: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub cover: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct UpdateReviewRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub review_type: Option<String>,
|
||||||
|
pub rating: Option<i32>,
|
||||||
|
pub review_date: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub cover: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
|
||||||
|
let reviews = ReviewEntity::find()
|
||||||
|
.order_by_desc(reviews::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
format::json(reviews)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_one(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
|
||||||
|
match review {
|
||||||
|
Some(r) => format::json(r),
|
||||||
|
None => Err(Error::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(req): Json<CreateReviewRequest>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let new_review = reviews::ActiveModel {
|
||||||
|
title: Set(Some(req.title)),
|
||||||
|
review_type: Set(Some(req.review_type)),
|
||||||
|
rating: Set(Some(req.rating)),
|
||||||
|
review_date: Set(Some(req.review_date)),
|
||||||
|
status: Set(Some(req.status)),
|
||||||
|
description: Set(Some(req.description)),
|
||||||
|
tags: Set(Some(serde_json::to_string(&req.tags).unwrap_or_default())),
|
||||||
|
cover: Set(Some(req.cover)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let review = new_review.insert(&ctx.db).await?;
|
||||||
|
format::json(review)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(req): Json<UpdateReviewRequest>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
|
||||||
|
let Some(mut review) = review.map(|r| r.into_active_model()) else {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(title) = req.title {
|
||||||
|
review.title = Set(Some(title));
|
||||||
|
}
|
||||||
|
if let Some(review_type) = req.review_type {
|
||||||
|
review.review_type = Set(Some(review_type));
|
||||||
|
}
|
||||||
|
if let Some(rating) = req.rating {
|
||||||
|
review.rating = Set(Some(rating));
|
||||||
|
}
|
||||||
|
if let Some(review_date) = req.review_date {
|
||||||
|
review.review_date = Set(Some(review_date));
|
||||||
|
}
|
||||||
|
if let Some(status) = req.status {
|
||||||
|
review.status = Set(Some(status));
|
||||||
|
}
|
||||||
|
if let Some(description) = req.description {
|
||||||
|
review.description = Set(Some(description));
|
||||||
|
}
|
||||||
|
if let Some(tags) = req.tags {
|
||||||
|
review.tags = Set(Some(serde_json::to_string(&tags).unwrap_or_default()));
|
||||||
|
}
|
||||||
|
if let Some(cover) = req.cover {
|
||||||
|
review.cover = Set(Some(cover));
|
||||||
|
}
|
||||||
|
|
||||||
|
let review = review.update(&ctx.db).await?;
|
||||||
|
format::json(review)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let review = ReviewEntity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
|
||||||
|
match review {
|
||||||
|
Some(r) => {
|
||||||
|
r.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
None => Err(Error::NotFound),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("/api/reviews")
|
||||||
|
.add("/", get(list).post(create))
|
||||||
|
.add("/{id}", get(get_one).put(update).delete(remove))
|
||||||
|
}
|
||||||
190
backend/src/controllers/search.rs
Normal file
190
backend/src/controllers/search.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ConnectionTrait, DatabaseBackend, DbBackend, FromQueryResult, Statement};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::models::_entities::posts;
|
||||||
|
use crate::services::content;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub q: Option<String>,
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, FromQueryResult)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub id: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub tags: Option<Value>,
|
||||||
|
pub post_type: Option<String>,
|
||||||
|
pub pinned: Option<bool>,
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub rank: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_sql() -> &'static str {
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.title,
|
||||||
|
p.slug,
|
||||||
|
p.description,
|
||||||
|
p.content,
|
||||||
|
p.category,
|
||||||
|
p.tags,
|
||||||
|
p.post_type,
|
||||||
|
p.pinned,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
ts_rank_cd(
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D'),
|
||||||
|
plainto_tsquery('simple', $1)
|
||||||
|
)::float8 AS rank
|
||||||
|
FROM posts p
|
||||||
|
WHERE (
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.description, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.category, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.tags::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(p.content, '')), 'D')
|
||||||
|
) @@ plainto_tsquery('simple', $1)
|
||||||
|
ORDER BY rank DESC, p.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_level_rank(post: &posts::Model, wanted: &str) -> f64 {
|
||||||
|
let wanted_lower = wanted.to_lowercase();
|
||||||
|
let mut rank = 0.0;
|
||||||
|
|
||||||
|
if post
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&wanted_lower)
|
||||||
|
{
|
||||||
|
rank += 4.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if post
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&wanted_lower)
|
||||||
|
{
|
||||||
|
rank += 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if post
|
||||||
|
.content
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&wanted_lower)
|
||||||
|
{
|
||||||
|
rank += 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if post
|
||||||
|
.category
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_lowercase()
|
||||||
|
.contains(&wanted_lower)
|
||||||
|
{
|
||||||
|
rank += 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if post
|
||||||
|
.tags
|
||||||
|
.as_ref()
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|tags| {
|
||||||
|
tags.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.any(|tag| tag.to_lowercase().contains(&wanted_lower))
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
rank += 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
rank
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fallback_search(ctx: &AppContext, q: &str, limit: u64) -> Result<Vec<SearchResult>> {
|
||||||
|
let mut results = posts::Entity::find().all(&ctx.db).await?;
|
||||||
|
results.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||||
|
|
||||||
|
Ok(results
|
||||||
|
.into_iter()
|
||||||
|
.map(|post| {
|
||||||
|
let rank = app_level_rank(&post, q);
|
||||||
|
(post, rank)
|
||||||
|
})
|
||||||
|
.filter(|(_, rank)| *rank > 0.0)
|
||||||
|
.take(limit as usize)
|
||||||
|
.map(|(post, rank)| SearchResult {
|
||||||
|
id: post.id,
|
||||||
|
title: post.title,
|
||||||
|
slug: post.slug,
|
||||||
|
description: post.description,
|
||||||
|
content: post.content,
|
||||||
|
category: post.category,
|
||||||
|
tags: post.tags,
|
||||||
|
post_type: post.post_type,
|
||||||
|
pinned: post.pinned,
|
||||||
|
created_at: post.created_at.into(),
|
||||||
|
updated_at: post.updated_at.into(),
|
||||||
|
rank,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn search(
|
||||||
|
Query(query): Query<SearchQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
|
||||||
|
let q = query.q.unwrap_or_default().trim().to_string();
|
||||||
|
if q.is_empty() {
|
||||||
|
return format::json(Vec::<SearchResult>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = query.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
|
||||||
|
let results = if ctx.db.get_database_backend() == DatabaseBackend::Postgres {
|
||||||
|
let statement = Statement::from_sql_and_values(
|
||||||
|
DbBackend::Postgres,
|
||||||
|
search_sql(),
|
||||||
|
[q.clone().into(), (limit as i64).into()],
|
||||||
|
);
|
||||||
|
|
||||||
|
match SearchResult::find_by_statement(statement).all(&ctx.db).await {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fallback_search(&ctx, &q, limit).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
format::json(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new().prefix("api/search/").add("/", get(search))
|
||||||
|
}
|
||||||
179
backend/src/controllers/site_settings.rs
Normal file
179
backend/src/controllers/site_settings.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct SiteSettingsPayload {
|
||||||
|
#[serde(default, alias = "siteName")]
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
#[serde(default, alias = "siteShortName")]
|
||||||
|
pub site_short_name: Option<String>,
|
||||||
|
#[serde(default, alias = "siteUrl")]
|
||||||
|
pub site_url: Option<String>,
|
||||||
|
#[serde(default, alias = "siteTitle")]
|
||||||
|
pub site_title: Option<String>,
|
||||||
|
#[serde(default, alias = "siteDescription")]
|
||||||
|
pub site_description: Option<String>,
|
||||||
|
#[serde(default, alias = "heroTitle")]
|
||||||
|
pub hero_title: Option<String>,
|
||||||
|
#[serde(default, alias = "heroSubtitle")]
|
||||||
|
pub hero_subtitle: Option<String>,
|
||||||
|
#[serde(default, alias = "ownerName")]
|
||||||
|
pub owner_name: Option<String>,
|
||||||
|
#[serde(default, alias = "ownerTitle")]
|
||||||
|
pub owner_title: Option<String>,
|
||||||
|
#[serde(default, alias = "ownerBio")]
|
||||||
|
pub owner_bio: Option<String>,
|
||||||
|
#[serde(default, alias = "ownerAvatarUrl")]
|
||||||
|
pub owner_avatar_url: Option<String>,
|
||||||
|
#[serde(default, alias = "socialGithub")]
|
||||||
|
pub social_github: Option<String>,
|
||||||
|
#[serde(default, alias = "socialTwitter")]
|
||||||
|
pub social_twitter: Option<String>,
|
||||||
|
#[serde(default, alias = "socialEmail")]
|
||||||
|
pub social_email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub location: Option<String>,
|
||||||
|
#[serde(default, alias = "techStack")]
|
||||||
|
pub tech_stack: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SiteSettingsPayload {
|
||||||
|
fn apply(self, item: &mut ActiveModel) {
|
||||||
|
if let Some(site_name) = self.site_name {
|
||||||
|
item.site_name = Set(normalize_optional_string(Some(site_name)));
|
||||||
|
}
|
||||||
|
if let Some(site_short_name) = self.site_short_name {
|
||||||
|
item.site_short_name = Set(normalize_optional_string(Some(site_short_name)));
|
||||||
|
}
|
||||||
|
if let Some(site_url) = self.site_url {
|
||||||
|
item.site_url = Set(normalize_optional_string(Some(site_url)));
|
||||||
|
}
|
||||||
|
if let Some(site_title) = self.site_title {
|
||||||
|
item.site_title = Set(normalize_optional_string(Some(site_title)));
|
||||||
|
}
|
||||||
|
if let Some(site_description) = self.site_description {
|
||||||
|
item.site_description = Set(normalize_optional_string(Some(site_description)));
|
||||||
|
}
|
||||||
|
if let Some(hero_title) = self.hero_title {
|
||||||
|
item.hero_title = Set(normalize_optional_string(Some(hero_title)));
|
||||||
|
}
|
||||||
|
if let Some(hero_subtitle) = self.hero_subtitle {
|
||||||
|
item.hero_subtitle = Set(normalize_optional_string(Some(hero_subtitle)));
|
||||||
|
}
|
||||||
|
if let Some(owner_name) = self.owner_name {
|
||||||
|
item.owner_name = Set(normalize_optional_string(Some(owner_name)));
|
||||||
|
}
|
||||||
|
if let Some(owner_title) = self.owner_title {
|
||||||
|
item.owner_title = Set(normalize_optional_string(Some(owner_title)));
|
||||||
|
}
|
||||||
|
if let Some(owner_bio) = self.owner_bio {
|
||||||
|
item.owner_bio = Set(normalize_optional_string(Some(owner_bio)));
|
||||||
|
}
|
||||||
|
if let Some(owner_avatar_url) = self.owner_avatar_url {
|
||||||
|
item.owner_avatar_url = Set(normalize_optional_string(Some(owner_avatar_url)));
|
||||||
|
}
|
||||||
|
if let Some(social_github) = self.social_github {
|
||||||
|
item.social_github = Set(normalize_optional_string(Some(social_github)));
|
||||||
|
}
|
||||||
|
if let Some(social_twitter) = self.social_twitter {
|
||||||
|
item.social_twitter = Set(normalize_optional_string(Some(social_twitter)));
|
||||||
|
}
|
||||||
|
if let Some(social_email) = self.social_email {
|
||||||
|
item.social_email = Set(normalize_optional_string(Some(social_email)));
|
||||||
|
}
|
||||||
|
if let Some(location) = self.location {
|
||||||
|
item.location = Set(normalize_optional_string(Some(location)));
|
||||||
|
}
|
||||||
|
if let Some(tech_stack) = self.tech_stack {
|
||||||
|
item.tech_stack = Set(Some(serde_json::json!(tech_stack)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_payload() -> SiteSettingsPayload {
|
||||||
|
SiteSettingsPayload {
|
||||||
|
site_name: Some("InitCool".to_string()),
|
||||||
|
site_short_name: Some("Termi".to_string()),
|
||||||
|
site_url: Some("https://termi.dev".to_string()),
|
||||||
|
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
|
||||||
|
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()),
|
||||||
|
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
|
||||||
|
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
|
||||||
|
owner_name: Some("InitCool".to_string()),
|
||||||
|
owner_title: Some("前端开发者 / 技术博主".to_string()),
|
||||||
|
owner_bio: Some(
|
||||||
|
"一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
owner_avatar_url: None,
|
||||||
|
social_github: Some("https://github.com".to_string()),
|
||||||
|
social_twitter: Some("https://twitter.com".to_string()),
|
||||||
|
social_email: Some("mailto:hello@termi.dev".to_string()),
|
||||||
|
location: Some("Hong Kong".to_string()),
|
||||||
|
tech_stack: Some(vec![
|
||||||
|
"Astro".to_string(),
|
||||||
|
"Svelte".to_string(),
|
||||||
|
"Tailwind CSS".to_string(),
|
||||||
|
"TypeScript".to_string(),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||||
|
if let Some(settings) = Entity::find()
|
||||||
|
.order_by_asc(site_settings::Column::Id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
id: Set(1),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
default_payload().apply(&mut item);
|
||||||
|
Ok(item.insert(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_current(&ctx).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<SiteSettingsPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let current = load_current(&ctx).await?;
|
||||||
|
let mut item = current.into_active_model();
|
||||||
|
params.apply(&mut item);
|
||||||
|
format::json(item.update(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/site_settings/")
|
||||||
|
.add("/", get(show))
|
||||||
|
.add("/", put(update))
|
||||||
|
.add("/", patch(update))
|
||||||
|
}
|
||||||
77
backend/src/controllers/tag.rs
Normal file
77
backend/src/controllers/tag.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::unnecessary_struct_initialization)]
|
||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::models::_entities::tags::{ActiveModel, Entity, Model};
|
||||||
|
use crate::services::content;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Params {
|
||||||
|
fn update(&self, item: &mut ActiveModel) {
|
||||||
|
item.name = Set(self.name.clone());
|
||||||
|
item.slug = Set(self.slug.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
|
item.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn list(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
|
format::json(Entity::find().all(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> {
|
||||||
|
let mut item = ActiveModel {
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.insert(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn update(
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<Params>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let mut item = item.into_active_model();
|
||||||
|
params.update(&mut item);
|
||||||
|
let item = item.update(&ctx.db).await?;
|
||||||
|
format::json(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn get_one(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
format::json(load_item(&ctx, id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/tags/")
|
||||||
|
.add("/", get(list))
|
||||||
|
.add("/", post(add))
|
||||||
|
.add("{id}", get(get_one))
|
||||||
|
.add("{id}", delete(remove))
|
||||||
|
.add("{id}", put(update))
|
||||||
|
.add("{id}", patch(update))
|
||||||
|
}
|
||||||
1
backend/src/data/mod.rs
Normal file
1
backend/src/data/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
48
backend/src/fixtures/comments.yaml
Normal file
48
backend/src/fixtures/comments.yaml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
- id: 1
|
||||||
|
pid: 1
|
||||||
|
author: "Alice"
|
||||||
|
email: "alice@example.com"
|
||||||
|
content: "Great introduction! Looking forward to more content."
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
pid: 1
|
||||||
|
author: "Bob"
|
||||||
|
email: "bob@example.com"
|
||||||
|
content: "The terminal UI looks amazing. Love the design!"
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
pid: 2
|
||||||
|
author: "Charlie"
|
||||||
|
email: "charlie@example.com"
|
||||||
|
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
pid: 3
|
||||||
|
author: "Diana"
|
||||||
|
email: "diana@example.com"
|
||||||
|
content: "Astro is indeed fast. I've been using it for my personal blog too."
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
pid: 4
|
||||||
|
author: "Eve"
|
||||||
|
email: "eve@example.com"
|
||||||
|
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
|
||||||
|
approved: true
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
pid: 5
|
||||||
|
author: "Frank"
|
||||||
|
email: "frank@example.com"
|
||||||
|
content: "Loco.rs looks promising. Might use it for my next project."
|
||||||
|
approved: false
|
||||||
|
|
||||||
|
- id: 7
|
||||||
|
pid: 2
|
||||||
|
author: "Grace"
|
||||||
|
email: "grace@example.com"
|
||||||
|
content: "Would love to see more advanced Rust patterns in future posts."
|
||||||
|
approved: true
|
||||||
38
backend/src/fixtures/friend_links.yaml
Normal file
38
backend/src/fixtures/friend_links.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
- id: 1
|
||||||
|
site_name: "Tech Blog Daily"
|
||||||
|
site_url: "https://techblog.example.com"
|
||||||
|
avatar_url: "https://techblog.example.com/avatar.png"
|
||||||
|
description: "Daily tech news and tutorials"
|
||||||
|
category: "tech"
|
||||||
|
status: "approved"
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
site_name: "Rustacean Station"
|
||||||
|
site_url: "https://rustacean.example.com"
|
||||||
|
avatar_url: "https://rustacean.example.com/logo.png"
|
||||||
|
description: "All things Rust programming"
|
||||||
|
category: "tech"
|
||||||
|
status: "approved"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
site_name: "Design Patterns"
|
||||||
|
site_url: "https://designpatterns.example.com"
|
||||||
|
avatar_url: "https://designpatterns.example.com/icon.png"
|
||||||
|
description: "UI/UX design inspiration"
|
||||||
|
category: "design"
|
||||||
|
status: "approved"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
site_name: "Code Snippets"
|
||||||
|
site_url: "https://codesnippets.example.com"
|
||||||
|
description: "Useful code snippets for developers"
|
||||||
|
category: "dev"
|
||||||
|
status: "pending"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
site_name: "Web Dev Weekly"
|
||||||
|
site_url: "https://webdevweekly.example.com"
|
||||||
|
avatar_url: "https://webdevweekly.example.com/favicon.png"
|
||||||
|
description: "Weekly web development newsletter"
|
||||||
|
category: "dev"
|
||||||
|
status: "pending"
|
||||||
191
backend/src/fixtures/posts.yaml
Normal file
191
backend/src/fixtures/posts.yaml
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
- id: 1
|
||||||
|
pid: 1
|
||||||
|
title: "Welcome to Termi Blog"
|
||||||
|
slug: "welcome-to-termi"
|
||||||
|
content: |
|
||||||
|
# Welcome to Termi Blog
|
||||||
|
|
||||||
|
This is the first post on our new blog built with Astro and Loco.rs backend.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🚀 Fast performance with Astro
|
||||||
|
- 🎨 Terminal-style UI design
|
||||||
|
- 💬 Comments system
|
||||||
|
- 🔗 Friend links
|
||||||
|
- 🏷️ Tags and categories
|
||||||
|
|
||||||
|
## Code Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
println!("Hello, Termi!");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stay tuned for more posts!
|
||||||
|
excerpt: "Welcome to our new blog built with Astro and Loco.rs backend."
|
||||||
|
category: "general"
|
||||||
|
published: true
|
||||||
|
pinned: true
|
||||||
|
tags:
|
||||||
|
- welcome
|
||||||
|
- astro
|
||||||
|
- loco-rs
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
pid: 2
|
||||||
|
title: "Rust Programming Tips"
|
||||||
|
slug: "rust-programming-tips"
|
||||||
|
content: |
|
||||||
|
# Rust Programming Tips
|
||||||
|
|
||||||
|
Here are some essential tips for Rust developers:
|
||||||
|
|
||||||
|
## 1. Ownership and Borrowing
|
||||||
|
|
||||||
|
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
||||||
|
|
||||||
|
## 2. Pattern Matching
|
||||||
|
|
||||||
|
Use `match` expressions for exhaustive pattern matching:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match result {
|
||||||
|
Ok(value) => println!("Success: {}", value),
|
||||||
|
Err(e) => println!("Error: {}", e),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Error Handling
|
||||||
|
|
||||||
|
Use `Result` and `Option` types effectively with the `?` operator.
|
||||||
|
|
||||||
|
Happy coding!
|
||||||
|
excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling."
|
||||||
|
category: "tech"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- rust
|
||||||
|
- programming
|
||||||
|
- tips
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
pid: 3
|
||||||
|
title: "Building a Blog with Astro"
|
||||||
|
slug: "building-blog-with-astro"
|
||||||
|
content: |
|
||||||
|
# Building a Blog with Astro
|
||||||
|
|
||||||
|
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||||
|
|
||||||
|
## Why Astro?
|
||||||
|
|
||||||
|
- **Zero JavaScript by default**: Ships less JavaScript to the client
|
||||||
|
- **Island Architecture**: Hydrate only interactive components
|
||||||
|
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
|
||||||
|
- **Great DX**: Excellent developer experience with hot module replacement
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create astro@latest
|
||||||
|
cd my-astro-project
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Astro is perfect for content-focused websites like blogs.
|
||||||
|
excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs."
|
||||||
|
category: "tech"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- astro
|
||||||
|
- web-dev
|
||||||
|
- static-site
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
pid: 4
|
||||||
|
title: "Terminal UI Design Principles"
|
||||||
|
slug: "terminal-ui-design"
|
||||||
|
content: |
|
||||||
|
# Terminal UI Design Principles
|
||||||
|
|
||||||
|
Terminal-style interfaces are making a comeback in modern web design.
|
||||||
|
|
||||||
|
## Key Elements
|
||||||
|
|
||||||
|
1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono
|
||||||
|
2. **Dark Themes**: Black or dark backgrounds with vibrant text colors
|
||||||
|
3. **Command Prompts**: Use `$` or `>` as visual indicators
|
||||||
|
4. **ASCII Art**: Decorative elements using text characters
|
||||||
|
5. **Blinking Cursor**: The iconic terminal cursor
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
- Background: `#0d1117`
|
||||||
|
- Text: `#c9d1d9`
|
||||||
|
- Accent: `#58a6ff`
|
||||||
|
- Success: `#3fb950`
|
||||||
|
- Warning: `#d29922`
|
||||||
|
- Error: `#f85149`
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
||||||
|
excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces."
|
||||||
|
category: "design"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- design
|
||||||
|
- terminal
|
||||||
|
- ui
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
pid: 5
|
||||||
|
title: "Loco.rs Backend Framework"
|
||||||
|
slug: "loco-rs-framework"
|
||||||
|
content: |
|
||||||
|
# Introduction to Loco.rs
|
||||||
|
|
||||||
|
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **MVC Architecture**: Model-View-Controller pattern
|
||||||
|
- **SeaORM Integration**: Powerful ORM for database operations
|
||||||
|
- **Background Jobs**: Built-in job processing
|
||||||
|
- **Authentication**: Ready-to-use auth system
|
||||||
|
- **CLI Generator**: Scaffold resources quickly
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install loco
|
||||||
|
loco new myapp
|
||||||
|
cd myapp
|
||||||
|
cargo loco start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Loco.rs?
|
||||||
|
|
||||||
|
- Opinionated but flexible
|
||||||
|
- Production-ready defaults
|
||||||
|
- Excellent documentation
|
||||||
|
- Active community
|
||||||
|
|
||||||
|
Perfect for building APIs and web applications in Rust.
|
||||||
|
excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust."
|
||||||
|
category: "tech"
|
||||||
|
published: true
|
||||||
|
pinned: false
|
||||||
|
tags:
|
||||||
|
- rust
|
||||||
|
- loco-rs
|
||||||
|
- backend
|
||||||
|
- api
|
||||||
59
backend/src/fixtures/reviews.yaml
Normal file
59
backend/src/fixtures/reviews.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
- id: 1
|
||||||
|
title: "塞尔达传说:王国之泪"
|
||||||
|
review_type: "game"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-03-20"
|
||||||
|
status: "completed"
|
||||||
|
description: "开放世界的巅峰之作,究极手能力带来无限创意空间"
|
||||||
|
tags: ["Switch", "开放世界", "冒险"]
|
||||||
|
cover: "🎮"
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
title: "进击的巨人"
|
||||||
|
review_type: "anime"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2023-11-10"
|
||||||
|
status: "completed"
|
||||||
|
description: "史诗级完结,剧情反转令人震撼"
|
||||||
|
tags: ["热血", "悬疑", "神作"]
|
||||||
|
cover: "🎭"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
title: "赛博朋克 2077"
|
||||||
|
review_type: "game"
|
||||||
|
rating: 4
|
||||||
|
review_date: "2024-01-15"
|
||||||
|
status: "completed"
|
||||||
|
description: "夜之城的故事,虽然首发有问题但后续更新很棒"
|
||||||
|
tags: ["PC", "RPG", "科幻"]
|
||||||
|
cover: "🎮"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
title: "三体"
|
||||||
|
review_type: "book"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2023-08-05"
|
||||||
|
status: "completed"
|
||||||
|
description: "硬科幻巅峰,宇宙社会学的黑暗森林法则"
|
||||||
|
tags: ["科幻", "经典", "雨果奖"]
|
||||||
|
cover: "📚"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
title: "星际穿越"
|
||||||
|
review_type: "movie"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-02-14"
|
||||||
|
status: "completed"
|
||||||
|
description: "诺兰神作,五维空间和黑洞的视觉奇观"
|
||||||
|
tags: ["科幻", "IMAX", "诺兰"]
|
||||||
|
cover: "🎬"
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
title: "博德之门3"
|
||||||
|
review_type: "game"
|
||||||
|
rating: 5
|
||||||
|
review_date: "2024-04-01"
|
||||||
|
status: "in-progress"
|
||||||
|
description: "CRPG的文艺复兴,骰子决定命运"
|
||||||
|
tags: ["PC", "CRPG", "多人"]
|
||||||
|
cover: "🎮"
|
||||||
21
backend/src/fixtures/site_settings.yaml
Normal file
21
backend/src/fixtures/site_settings.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
- id: 1
|
||||||
|
site_name: "InitCool"
|
||||||
|
site_short_name: "Termi"
|
||||||
|
site_url: "https://termi.dev"
|
||||||
|
site_title: "InitCool - 终端风格的内容平台"
|
||||||
|
site_description: "一个基于终端美学的个人内容站,记录代码、设计和生活。"
|
||||||
|
hero_title: "欢迎来到我的极客终端博客"
|
||||||
|
hero_subtitle: "这里记录技术、代码和生活点滴"
|
||||||
|
owner_name: "InitCool"
|
||||||
|
owner_title: "前端开发者 / 技术博主"
|
||||||
|
owner_bio: "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。"
|
||||||
|
owner_avatar_url: ""
|
||||||
|
social_github: "https://github.com"
|
||||||
|
social_twitter: "https://twitter.com"
|
||||||
|
social_email: "mailto:hello@termi.dev"
|
||||||
|
location: "Hong Kong"
|
||||||
|
tech_stack:
|
||||||
|
- "Astro"
|
||||||
|
- "Svelte"
|
||||||
|
- "Tailwind CSS"
|
||||||
|
- "TypeScript"
|
||||||
39
backend/src/fixtures/tags.yaml
Normal file
39
backend/src/fixtures/tags.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
- id: 1
|
||||||
|
name: "Welcome"
|
||||||
|
slug: "welcome"
|
||||||
|
|
||||||
|
- id: 2
|
||||||
|
name: "Astro"
|
||||||
|
slug: "astro"
|
||||||
|
|
||||||
|
- id: 3
|
||||||
|
name: "Rust"
|
||||||
|
slug: "rust"
|
||||||
|
|
||||||
|
- id: 4
|
||||||
|
name: "Programming"
|
||||||
|
slug: "programming"
|
||||||
|
|
||||||
|
- id: 5
|
||||||
|
name: "Tech"
|
||||||
|
slug: "tech"
|
||||||
|
|
||||||
|
- id: 6
|
||||||
|
name: "Design"
|
||||||
|
slug: "design"
|
||||||
|
|
||||||
|
- id: 7
|
||||||
|
name: "Terminal"
|
||||||
|
slug: "terminal"
|
||||||
|
|
||||||
|
- id: 8
|
||||||
|
name: "Loco.rs"
|
||||||
|
slug: "loco-rs"
|
||||||
|
|
||||||
|
- id: 9
|
||||||
|
name: "Backend"
|
||||||
|
slug: "backend"
|
||||||
|
|
||||||
|
- id: 10
|
||||||
|
name: "Web Dev"
|
||||||
|
slug: "web-dev"
|
||||||
17
backend/src/fixtures/users.yaml
Normal file
17
backend/src/fixtures/users.yaml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
- id: 1
|
||||||
|
pid: 11111111-1111-1111-1111-111111111111
|
||||||
|
email: user1@example.com
|
||||||
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||||
|
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||||
|
name: user1
|
||||||
|
created_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
updated_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
- id: 2
|
||||||
|
pid: 22222222-2222-2222-2222-222222222222
|
||||||
|
email: user2@example.com
|
||||||
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||||
|
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||||
|
name: user2
|
||||||
|
created_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
updated_at: "2023-11-12T12:34:56.789Z"
|
||||||
191
backend/src/initializers/content_sync.rs
Normal file
191
backend/src/initializers/content_sync.rs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use loco_rs::{
|
||||||
|
app::{AppContext, Initializer},
|
||||||
|
Result,
|
||||||
|
};
|
||||||
|
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::models::_entities::{comments, posts, site_settings};
|
||||||
|
use crate::services::content;
|
||||||
|
|
||||||
|
const FIXTURES_DIR: &str = "src/fixtures";
|
||||||
|
|
||||||
|
pub struct ContentSyncInitializer;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Initializer for ContentSyncInitializer {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"content-sync".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn before_run(&self, app_context: &AppContext) -> Result<()> {
|
||||||
|
sync_content(app_context, Path::new(FIXTURES_DIR)).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_content(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
|
content::sync_markdown_posts(ctx).await?;
|
||||||
|
sync_site_settings(ctx, base).await?;
|
||||||
|
sync_comment_post_slugs(ctx, base).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_fixture_rows(base: &Path, file_name: &str) -> Vec<serde_json::Value> {
|
||||||
|
let path: PathBuf = base.join(file_name);
|
||||||
|
let seed_data = match std::fs::read_to_string(path) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_yaml::from_str(&seed_data).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_optional_string(value: &serde_json::Value) -> Option<String> {
|
||||||
|
value.as_str().and_then(|item| {
|
||||||
|
let trimmed = item.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_blank(value: &Option<String>) -> bool {
|
||||||
|
value.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
|
let rows = read_fixture_rows(base, "site_settings.yaml");
|
||||||
|
let Some(seed) = rows.first() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let tech_stack = seed["tech_stack"]
|
||||||
|
.as_array()
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_str())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.filter(|items| !items.is_empty())
|
||||||
|
.map(|items| serde_json::json!(items));
|
||||||
|
|
||||||
|
let existing = site_settings::Entity::find()
|
||||||
|
.order_by_asc(site_settings::Column::Id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
let mut model = existing.clone().into_active_model();
|
||||||
|
|
||||||
|
if is_blank(&existing.site_name) {
|
||||||
|
model.site_name = Set(as_optional_string(&seed["site_name"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.site_short_name) {
|
||||||
|
model.site_short_name = Set(as_optional_string(&seed["site_short_name"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.site_url) {
|
||||||
|
model.site_url = Set(as_optional_string(&seed["site_url"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.site_title) {
|
||||||
|
model.site_title = Set(as_optional_string(&seed["site_title"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.site_description) {
|
||||||
|
model.site_description = Set(as_optional_string(&seed["site_description"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.hero_title) {
|
||||||
|
model.hero_title = Set(as_optional_string(&seed["hero_title"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.hero_subtitle) {
|
||||||
|
model.hero_subtitle = Set(as_optional_string(&seed["hero_subtitle"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.owner_name) {
|
||||||
|
model.owner_name = Set(as_optional_string(&seed["owner_name"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.owner_title) {
|
||||||
|
model.owner_title = Set(as_optional_string(&seed["owner_title"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.owner_bio) {
|
||||||
|
model.owner_bio = Set(as_optional_string(&seed["owner_bio"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.owner_avatar_url) {
|
||||||
|
model.owner_avatar_url = Set(as_optional_string(&seed["owner_avatar_url"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.social_github) {
|
||||||
|
model.social_github = Set(as_optional_string(&seed["social_github"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.social_twitter) {
|
||||||
|
model.social_twitter = Set(as_optional_string(&seed["social_twitter"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.social_email) {
|
||||||
|
model.social_email = Set(as_optional_string(&seed["social_email"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.location) {
|
||||||
|
model.location = Set(as_optional_string(&seed["location"]));
|
||||||
|
}
|
||||||
|
if existing.tech_stack.is_none() {
|
||||||
|
model.tech_stack = Set(tech_stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = model.update(&ctx.db).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = site_settings::ActiveModel {
|
||||||
|
id: Set(seed["id"].as_i64().unwrap_or(1) as i32),
|
||||||
|
site_name: Set(as_optional_string(&seed["site_name"])),
|
||||||
|
site_short_name: Set(as_optional_string(&seed["site_short_name"])),
|
||||||
|
site_url: Set(as_optional_string(&seed["site_url"])),
|
||||||
|
site_title: Set(as_optional_string(&seed["site_title"])),
|
||||||
|
site_description: Set(as_optional_string(&seed["site_description"])),
|
||||||
|
hero_title: Set(as_optional_string(&seed["hero_title"])),
|
||||||
|
hero_subtitle: Set(as_optional_string(&seed["hero_subtitle"])),
|
||||||
|
owner_name: Set(as_optional_string(&seed["owner_name"])),
|
||||||
|
owner_title: Set(as_optional_string(&seed["owner_title"])),
|
||||||
|
owner_bio: Set(as_optional_string(&seed["owner_bio"])),
|
||||||
|
owner_avatar_url: Set(as_optional_string(&seed["owner_avatar_url"])),
|
||||||
|
social_github: Set(as_optional_string(&seed["social_github"])),
|
||||||
|
social_twitter: Set(as_optional_string(&seed["social_twitter"])),
|
||||||
|
social_email: Set(as_optional_string(&seed["social_email"])),
|
||||||
|
location: Set(as_optional_string(&seed["location"])),
|
||||||
|
tech_stack: Set(tech_stack),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = model.insert(&ctx.db).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_comment_post_slugs(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
|
let rows = read_fixture_rows(base, "comments.yaml");
|
||||||
|
|
||||||
|
for seed in rows {
|
||||||
|
let id = seed["id"].as_i64().unwrap_or(0) as i32;
|
||||||
|
let pid = seed["pid"].as_i64().unwrap_or(0) as i32;
|
||||||
|
if id == 0 || pid == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(existing) = comments::Entity::find_by_id(id).one(&ctx.db).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if existing.post_slug.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(post) = posts::Entity::find_by_id(pid).one(&ctx.db).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut model = existing.into_active_model();
|
||||||
|
model.post_slug = Set(Some(post.slug));
|
||||||
|
let _ = model.update(&ctx.db).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2
backend/src/initializers/mod.rs
Normal file
2
backend/src/initializers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod content_sync;
|
||||||
|
pub mod view_engine;
|
||||||
43
backend/src/initializers/view_engine.rs
Normal file
43
backend/src/initializers/view_engine.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::{Extension, Router as AxumRouter};
|
||||||
|
use fluent_templates::{ArcLoader, FluentLoader};
|
||||||
|
use loco_rs::{
|
||||||
|
app::{AppContext, Initializer},
|
||||||
|
controller::views::{engines, ViewEngine},
|
||||||
|
Error, Result,
|
||||||
|
};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
const I18N_DIR: &str = "assets/i18n";
|
||||||
|
const I18N_SHARED: &str = "assets/i18n/shared.ftl";
|
||||||
|
#[allow(clippy::module_name_repetitions)]
|
||||||
|
pub struct ViewEngineInitializer;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Initializer for ViewEngineInitializer {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"view-engine".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||||
|
let tera_engine = if std::path::Path::new(I18N_DIR).exists() {
|
||||||
|
let arc = std::sync::Arc::new(
|
||||||
|
ArcLoader::builder(&I18N_DIR, unic_langid::langid!("en-US"))
|
||||||
|
.shared_resources(Some(&[I18N_SHARED.into()]))
|
||||||
|
.customize(|bundle| bundle.set_use_isolating(false))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| Error::string(&e.to_string()))?,
|
||||||
|
);
|
||||||
|
info!("locales loaded");
|
||||||
|
|
||||||
|
engines::TeraView::build()?.post_process(move |tera| {
|
||||||
|
tera.register_function("t", FluentLoader::new(arc.clone()));
|
||||||
|
Ok(())
|
||||||
|
})?
|
||||||
|
} else {
|
||||||
|
engines::TeraView::build()?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(router.layer(Extension(ViewEngine::from(tera_engine))))
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/lib.rs
Normal file
10
backend/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod controllers;
|
||||||
|
pub mod data;
|
||||||
|
pub mod initializers;
|
||||||
|
pub mod mailers;
|
||||||
|
pub mod models;
|
||||||
|
pub mod services;
|
||||||
|
pub mod tasks;
|
||||||
|
pub mod views;
|
||||||
|
pub mod workers;
|
||||||
90
backend/src/mailers/auth.rs
Normal file
90
backend/src/mailers/auth.rs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// auth mailer
|
||||||
|
#![allow(non_upper_case_globals)]
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::models::users;
|
||||||
|
|
||||||
|
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||||
|
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||||
|
static magic_link: Dir<'_> = include_dir!("src/mailers/auth/magic_link");
|
||||||
|
|
||||||
|
#[allow(clippy::module_name_repetitions)]
|
||||||
|
pub struct AuthMailer {}
|
||||||
|
impl Mailer for AuthMailer {}
|
||||||
|
impl AuthMailer {
|
||||||
|
/// Sending welcome email the the given user
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When email sending is failed
|
||||||
|
pub async fn send_welcome(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||||
|
Self::mail_template(
|
||||||
|
ctx,
|
||||||
|
&welcome,
|
||||||
|
mailer::Args {
|
||||||
|
to: user.email.to_string(),
|
||||||
|
locals: json!({
|
||||||
|
"name": user.name,
|
||||||
|
"verifyToken": user.email_verification_token,
|
||||||
|
"domain": ctx.config.server.full_url()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sending forgot password email
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When email sending is failed
|
||||||
|
pub async fn forgot_password(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||||
|
Self::mail_template(
|
||||||
|
ctx,
|
||||||
|
&forgot,
|
||||||
|
mailer::Args {
|
||||||
|
to: user.email.to_string(),
|
||||||
|
locals: json!({
|
||||||
|
"name": user.name,
|
||||||
|
"resetToken": user.reset_token,
|
||||||
|
"domain": ctx.config.server.full_url()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a magic link authentication email to the user.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When email sending is failed
|
||||||
|
pub async fn send_magic_link(ctx: &AppContext, user: &users::Model) -> Result<()> {
|
||||||
|
Self::mail_template(
|
||||||
|
ctx,
|
||||||
|
&magic_link,
|
||||||
|
mailer::Args {
|
||||||
|
to: user.email.to_string(),
|
||||||
|
locals: json!({
|
||||||
|
"name": user.name,
|
||||||
|
"token": user.magic_link_token.clone().ok_or_else(|| Error::string(
|
||||||
|
"the user model not contains magic link token",
|
||||||
|
))?,
|
||||||
|
"host": ctx.config.server.full_url()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
11
backend/src/mailers/auth/forgot/html.t
Normal file
11
backend/src/mailers/auth/forgot/html.t
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;<html>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
Hey {{name}},
|
||||||
|
Forgot your password? No worries! You can reset it by clicking the link below:
|
||||||
|
<a href="http://{{domain}}/reset#{{resetToken}}">Reset Your Password</a>
|
||||||
|
If you didn't request a password reset, please ignore this email.
|
||||||
|
Best regards,<br>The Loco Team</br>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
1
backend/src/mailers/auth/forgot/subject.t
Normal file
1
backend/src/mailers/auth/forgot/subject.t
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Your reset password link
|
||||||
3
backend/src/mailers/auth/forgot/text.t
Normal file
3
backend/src/mailers/auth/forgot/text.t
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Reset your password with this link:
|
||||||
|
|
||||||
|
http://localhost/reset#{{resetToken}}
|
||||||
8
backend/src/mailers/auth/magic_link/html.t
Normal file
8
backend/src/mailers/auth/magic_link/html.t
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
;<html>
|
||||||
|
<body>
|
||||||
|
<p>Magic link example:</p>
|
||||||
|
<a href="{{host}}/api/auth/magic-link/{{token}}">
|
||||||
|
Verify Your Account
|
||||||
|
</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
backend/src/mailers/auth/magic_link/subject.t
Normal file
1
backend/src/mailers/auth/magic_link/subject.t
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Magic link example
|
||||||
2
backend/src/mailers/auth/magic_link/text.t
Normal file
2
backend/src/mailers/auth/magic_link/text.t
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Magic link with this link:
|
||||||
|
{{host}}/api/auth/magic-link/{{token}}
|
||||||
13
backend/src/mailers/auth/welcome/html.t
Normal file
13
backend/src/mailers/auth/welcome/html.t
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;<html>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
Dear {{name}},
|
||||||
|
Welcome to Loco! You can now log in to your account.
|
||||||
|
Before you get started, please verify your account by clicking the link below:
|
||||||
|
<a href="{{domain}}/api/auth/verify/{{verifyToken}}">
|
||||||
|
Verify Your Account
|
||||||
|
</a>
|
||||||
|
<p>Best regards,<br>The Loco Team</p>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
1
backend/src/mailers/auth/welcome/subject.t
Normal file
1
backend/src/mailers/auth/welcome/subject.t
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome {{name}}
|
||||||
4
backend/src/mailers/auth/welcome/text.t
Normal file
4
backend/src/mailers/auth/welcome/text.t
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Welcome {{name}}, you can now log in.
|
||||||
|
Verify your account with the link below:
|
||||||
|
|
||||||
|
{{domain}}/api/auth/verify/{{verifyToken}}
|
||||||
1
backend/src/mailers/mod.rs
Normal file
1
backend/src/mailers/mod.rs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
pub mod auth;
|
||||||
16
backend/src/models/_entities/categories.rs
Normal file
16
backend/src/models/_entities/categories.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "categories")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
25
backend/src/models/_entities/comments.rs
Normal file
25
backend/src/models/_entities/comments.rs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "comments")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub post_id: Option<Uuid>,
|
||||||
|
pub post_slug: Option<String>,
|
||||||
|
pub author: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub reply_to: Option<Uuid>,
|
||||||
|
pub approved: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
22
backend/src/models/_entities/friend_links.rs
Normal file
22
backend/src/models/_entities/friend_links.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "friend_links")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub site_url: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
12
backend/src/models/_entities/mod.rs
Normal file
12
backend/src/models/_entities/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod categories;
|
||||||
|
pub mod comments;
|
||||||
|
pub mod friend_links;
|
||||||
|
pub mod posts;
|
||||||
|
pub mod reviews;
|
||||||
|
pub mod site_settings;
|
||||||
|
pub mod tags;
|
||||||
|
pub mod users;
|
||||||
27
backend/src/models/_entities/posts.rs
Normal file
27
backend/src/models/_entities/posts.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "posts")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||||
|
pub tags: Option<Json>,
|
||||||
|
pub post_type: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub pinned: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
10
backend/src/models/_entities/prelude.rs
Normal file
10
backend/src/models/_entities/prelude.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
pub use super::categories::Entity as Categories;
|
||||||
|
pub use super::comments::Entity as Comments;
|
||||||
|
pub use super::friend_links::Entity as FriendLinks;
|
||||||
|
pub use super::posts::Entity as Posts;
|
||||||
|
pub use super::reviews::Entity as Reviews;
|
||||||
|
pub use super::site_settings::Entity as SiteSettings;
|
||||||
|
pub use super::tags::Entity as Tags;
|
||||||
|
pub use super::users::Entity as Users;
|
||||||
24
backend/src/models/_entities/reviews.rs
Normal file
24
backend/src/models/_entities/reviews.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "reviews")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub review_type: Option<String>,
|
||||||
|
pub rating: Option<i32>,
|
||||||
|
pub review_date: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub tags: Option<String>,
|
||||||
|
pub cover: Option<String>,
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
36
backend/src/models/_entities/site_settings.rs
Normal file
36
backend/src/models/_entities/site_settings.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//! `SeaORM` Entity, manually maintained
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "site_settings")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub site_short_name: Option<String>,
|
||||||
|
pub site_url: Option<String>,
|
||||||
|
pub site_title: Option<String>,
|
||||||
|
pub site_description: Option<String>,
|
||||||
|
pub hero_title: Option<String>,
|
||||||
|
pub hero_subtitle: Option<String>,
|
||||||
|
pub owner_name: Option<String>,
|
||||||
|
pub owner_title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub owner_bio: Option<String>,
|
||||||
|
pub owner_avatar_url: Option<String>,
|
||||||
|
pub social_github: Option<String>,
|
||||||
|
pub social_twitter: Option<String>,
|
||||||
|
pub social_email: Option<String>,
|
||||||
|
pub location: Option<String>,
|
||||||
|
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||||
|
pub tech_stack: Option<Json>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
18
backend/src/models/_entities/tags.rs
Normal file
18
backend/src/models/_entities/tags.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "tags")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub slug: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
30
backend/src/models/_entities/users.rs
Normal file
30
backend/src/models/_entities/users.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub pid: Uuid,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub api_key: String,
|
||||||
|
pub name: String,
|
||||||
|
pub reset_token: Option<String>,
|
||||||
|
pub reset_sent_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub email_verification_token: Option<String>,
|
||||||
|
pub email_verification_sent_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub email_verified_at: Option<DateTimeWithTimeZone>,
|
||||||
|
pub magic_link_token: Option<String>,
|
||||||
|
pub magic_link_expiration: Option<DateTimeWithTimeZone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
23
backend/src/models/categories.rs
Normal file
23
backend/src/models/categories.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pub use super::_entities::categories::{ActiveModel, Entity, Model};
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub type Categories = 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 {}
|
||||||
28
backend/src/models/comments.rs
Normal file
28
backend/src/models/comments.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
pub use super::_entities::comments::{ActiveModel, Entity, Model};
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub type Comments = 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 {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user