feat: add admin publishing workflow and yar theme

Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

30
frontend/admin/README.md Normal file
View file

@ -0,0 +1,30 @@
# Osaet Admin
Angular admin UI for the Osaet Gin API.
## Development
Start the Go admin API first:
```bash
cd /home/yarnom/Codes/osaet/backend
DATABASE_URL='postgres://yarnom:数据库密码@10.66.0.30:5432/osaet?sslmode=disable' \
go run ./cmd/osaet-admin serve
```
Install dependencies and start Angular:
```bash
cd /home/yarnom/Codes/osaet/frontend/admin
npm install
npm start
```
Open:
```text
http://127.0.0.1:4200/
```
The Angular dev server proxies `/api`, `/healthz`, and `/readyz` to
`http://127.0.0.1:8080`.

View file

@ -0,0 +1,62 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"admin": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "osaet",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/admin",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "admin:build:production"
},
"development": {
"buildTarget": "admin:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
},
"cli": {
"analytics": "1f777743-c088-4a64-b88c-12e453da36c1"
}
}

13966
frontend/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
{
"name": "@osaet/admin",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "ng serve --host 127.0.0.1 --port 4200 --proxy-config proxy.conf.json",
"build": "ng build",
"watch": "ng build --watch --configuration development"
},
"dependencies": {
"@angular/animations": "^20.0.0",
"@angular/common": "^20.0.0",
"@angular/compiler": "^20.0.0",
"@angular/core": "^20.0.0",
"@angular/forms": "^20.0.0",
"@angular/platform-browser": "^20.0.0",
"@angular/router": "^20.0.0",
"rxjs": "^7.8.0",
"tslib": "^2.8.0",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular/cli": "^20.0.0",
"@angular/compiler-cli": "^20.0.0",
"@angular-devkit/build-angular": "^20.0.0",
"typescript": "~5.8.0"
}
}

View file

@ -0,0 +1,17 @@
{
"/api": {
"target": "http://127.0.0.1:8080",
"secure": false,
"changeOrigin": true
},
"/healthz": {
"target": "http://127.0.0.1:8080",
"secure": false,
"changeOrigin": true
},
"/readyz": {
"target": "http://127.0.0.1:8080",
"secure": false,
"changeOrigin": true
}
}

View file

@ -0,0 +1,117 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import {
AssetResponse,
BuildJobResponse,
LoginResponse,
DeletePostResponse,
PostInput,
PostResponse,
PostStatus,
PostsResponse,
SlugResponse
} from './models';
@Injectable({ providedIn: 'root' })
export class AdminApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/admin';
me() {
return this.http.get<{ user: LoginResponse['user'] }>(`${this.baseUrl}/me`, {
withCredentials: true
});
}
login(username: string, password: string) {
return this.http.post<LoginResponse>(
`${this.baseUrl}/login`,
{ username, password },
{ withCredentials: true }
);
}
logout() {
return this.http.post<{ ok: boolean }>(`${this.baseUrl}/logout`, {}, { withCredentials: true });
}
listPosts(status: PostStatus | '', limit?: number, offset?: number) {
let params = new HttpParams();
if (status) {
params = params.set('status', status);
}
if (limit) {
params = params.set('limit', String(limit));
}
if (offset) {
params = params.set('offset', String(offset));
}
return this.http.get<PostsResponse>(`${this.baseUrl}/posts`, {
params,
withCredentials: true
});
}
getPost(id: string) {
return this.http.get<PostResponse>(`${this.baseUrl}/posts/${id}`, {
withCredentials: true
});
}
createPost(input: PostInput) {
return this.http.post<PostResponse>(`${this.baseUrl}/posts`, input, {
withCredentials: true
});
}
updatePost(id: string, input: PostInput) {
return this.http.put<PostResponse>(`${this.baseUrl}/posts/${id}`, input, {
withCredentials: true
});
}
deletePost(id: string) {
return this.http.delete<DeletePostResponse>(`${this.baseUrl}/posts/${id}`, {
withCredentials: true
});
}
publishPost(id: string) {
return this.http.post<PostResponse & BuildJobResponse>(
`${this.baseUrl}/posts/${id}/publish`,
{},
{ withCredentials: true }
);
}
buildPost(id: string) {
return this.http.post<BuildJobResponse>(
`${this.baseUrl}/posts/${id}/build`,
{},
{ withCredentials: true }
);
}
getBuildJob(id: string) {
return this.http.get<BuildJobResponse>(`${this.baseUrl}/build-jobs/${id}`, {
withCredentials: true
});
}
uploadAsset(file: File) {
const body = new FormData();
body.append('file', file);
return this.http.post<AssetResponse>(`${this.baseUrl}/assets`, body, {
withCredentials: true
});
}
generateSlug(title: string, summary: string, postId?: string) {
return this.http.post<SlugResponse>(
`${this.baseUrl}/slug`,
{ title, summary, postId },
{ withCredentials: true }
);
}
}

View file

@ -0,0 +1,807 @@
:host {
display: block;
min-height: 100vh;
}
button {
border: 0;
border-radius: 0.7em;
padding: 0.72em 1.15em;
background: #243b53;
color: #fff;
cursor: pointer;
font: inherit;
line-height: 1;
}
button:hover {
background: #1c3147;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.upload-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.7em;
background: #fff;
color: #2f4a63;
cursor: pointer;
padding: 0.72em 1.15em;
}
.upload-button:hover {
background: #f1f3f5;
}
.upload-button input {
display: none;
}
.link-button {
background: transparent;
color: #2f4a63;
padding: 0.45em 0.65em;
text-decoration: none;
}
.link-button:hover {
background: #f1f3f5;
color: #1c3147;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #e8e5df;
border-radius: 0.7em;
background: #fff;
color: #232428;
padding: 0.8em 0.95em;
outline: 0;
}
input:focus,
select:focus,
textarea:focus {
border-color: #9aa9b6;
box-shadow: 0 0 0 0.14em rgb(36 59 83 / 5%);
}
label {
display: grid;
gap: 0.5em;
color: #55575d;
font-size: 0.9em;
}
textarea {
resize: vertical;
line-height: 1.7;
}
.loading-shell,
.shell {
width: 100%;
min-height: 100vh;
background: #fff;
}
.loading-shell {
display: grid;
place-items: center;
color: #777b82;
}
.login-view {
min-height: 100vh;
display: grid;
place-items: center;
background: #fff;
}
.login-panel {
width: min(90vw, 27em);
display: grid;
gap: 1.2em;
border: 1px solid #eee;
padding: 2em;
border-radius: 0.9em;
background: #fff;
}
.topbar {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1em 25%;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.6em;
min-width: 0;
color: #777b82;
font-size: 0.92em;
}
.breadcrumb a,
.breadcrumb button {
border-radius: 0.65em;
background: transparent;
color: #2f4a63;
padding: 0.35em 0.45em;
text-decoration: none;
}
.breadcrumb a:hover,
.breadcrumb button:hover {
background: #f1f3f5;
color: #1c3147;
}
.breadcrumb span:last-child {
overflow: hidden;
color: #3d3d3f;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-menu {
position: relative;
flex: 0 0 auto;
}
.user-menu summary {
list-style: none;
border-radius: 0.75em;
color: #2f4a63;
cursor: pointer;
padding: 0.55em 0.8em;
}
.user-menu summary::-webkit-details-marker {
display: none;
}
.user-menu summary::after {
content: "⌄";
margin-left: 0.45em;
color: #777b82;
}
.user-menu summary:hover {
background: #f1f3f5;
}
.user-menu-panel {
position: absolute;
top: calc(100% + 0.45em);
right: 0;
z-index: 10;
min-width: 8em;
display: grid;
gap: 0.2em;
border: 1px solid #eee;
border-radius: 0.75em;
background: #fff;
padding: 0.45em;
}
.user-menu-panel button {
width: 100%;
border-radius: 0.55em;
background: transparent;
color: #2f4a63;
padding: 0.6em 0.8em;
text-align: left;
}
.user-menu-panel button:hover {
background: #f1f3f5;
color: #1c3147;
}
.list-view,
.editor-view {
width: 50%;
margin: 0 auto;
padding: 3em 0 4em;
}
.feedback-toast {
position: fixed;
top: 1.2em;
right: 1.2em;
z-index: 30;
width: min(92vw, 22em);
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1em;
border: 1px solid #eee;
border-left: 0.28em solid #2f7d4f;
border-radius: 0.75em;
background: #fff;
box-shadow: 0 0.25em 1.2em rgb(35 36 40 / 8%);
color: #3d3d3f;
padding: 0.9em 1em;
}
.feedback-toast.info {
border-left-color: #2f4a63;
}
.feedback-toast.error {
border-left-color: #9b332c;
}
.feedback-toast div {
display: grid;
gap: 0.3em;
}
.feedback-toast strong {
color: #232428;
font-size: 0.92em;
}
.feedback-toast span {
color: #676b72;
font-size: 0.86em;
line-height: 1.45;
}
.feedback-toast button {
flex: 0 0 auto;
border-radius: 50%;
background: transparent;
color: #777b82;
padding: 0.25em 0.4em;
}
.feedback-toast button:hover {
background: #f1f3f5;
color: #1c3147;
}
.page-heading,
.editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 2em;
}
.page-heading h1,
.editor-head h1,
.login-panel h1 {
margin: 0;
line-height: 1.2;
}
.page-actions {
display: flex;
align-items: center;
gap: 0.6em;
}
.page-actions select,
.page-actions button {
width: auto;
min-height: 2.55em;
border-radius: 0.65em;
font-size: 0.9em;
line-height: 1;
}
.page-actions select {
min-width: 8em;
padding: 0 2.2em 0 0.9em;
}
.page-actions button {
padding: 0 1em;
}
.editor-head h1 {
display: flex;
align-items: center;
gap: 0.4em;
}
.eyebrow {
margin: 0 0 0.35em;
color: #8b8175;
font-size: 0.78em;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.dirty-dot {
width: 0.45em;
height: 0.45em;
border-radius: 50%;
background: #a86a2d;
}
.post-table {
--post-table-columns: minmax(0, 1.5fr) minmax(8em, 0.8fr) 6em 10em 5.6em;
display: grid;
border-top: 1px solid #eee;
}
.post-table-head,
.post-table-row {
width: 100%;
display: grid;
grid-template-columns: var(--post-table-columns);
align-items: center;
gap: 1em;
border-bottom: 1px solid #eee;
padding: 0.9em 0.75em;
}
.post-table-head {
color: #777b82;
line-height: 1.2;
}
.post-table-head span {
font-size: 0.78em;
}
.post-table-row {
background: #fff;
color: #232428;
cursor: pointer;
text-align: left;
}
.post-table-row:hover {
background: #fafafa;
}
.post-row-title {
overflow: hidden;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-row-tags,
.post-table-row > span {
color: #777b82;
font-size: 0.86em;
}
.row-actions {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.75em;
}
.table-action {
color: #2f4a63;
cursor: pointer;
}
.table-action:hover {
color: #1c3147;
}
.table-action.danger {
color: #9b332c;
}
.empty-message {
margin: 3em 0;
color: #777b82;
text-align: center;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.45em;
margin-top: 2em;
color: #6d7179;
font-size: 0.9em;
}
.page-numbers {
display: inline-flex;
align-items: center;
gap: 0.45em;
}
.page-numbers {
justify-content: center;
}
.page-number {
min-width: 2.2em;
border-radius: 0.55em;
background: transparent;
color: #2f4a63;
padding: 0.45em 0.65em;
}
.page-number:hover,
.page-number.active {
background: #f1f3f5;
color: #1c3147;
}
.editor-form {
display: grid;
gap: 1.2em;
}
.editor-actions {
display: inline-flex;
align-items: center;
gap: 0.25em;
border: 1px solid #eee;
border-radius: 0.8em;
background: #fff;
padding: 0.35em;
box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%);
}
.editor-actions button,
.editor-actions .upload-button {
min-height: 2.45em;
border-radius: 0.62em;
padding: 0 0.9em;
font-size: 0.88em;
white-space: nowrap;
}
.editor-actions .upload-button,
.editor-actions .save-button,
.editor-actions .build-button {
background: transparent;
color: #2f4a63;
}
.editor-actions .upload-button:hover,
.editor-actions .save-button:hover,
.editor-actions .build-button:hover {
background: #243b53;
color: #fff;
}
.fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1em;
}
.slug-control {
position: relative;
display: block;
}
.slug-control input {
padding-right: 3.05em;
}
.slug-ai-button {
position: absolute;
top: 50%;
right: 0.35em;
width: 2.25em;
height: 2.25em;
display: inline-grid;
place-items: center;
border-radius: 0.62em;
background: #fff;
color: #2f4a63;
padding: 0;
transform: translateY(-50%);
}
.slug-ai-button:hover {
background: #f1f3f5;
color: #1c3147;
}
.slug-ai-button span {
display: block;
font-size: 1.05em;
line-height: 1;
}
.wide-field {
grid-column: 1 / -1;
}
.body-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
}
.mode-switch {
display: inline-flex;
align-items: center;
gap: 0.2em;
border: 1px solid #eee;
border-radius: 0.7em;
padding: 0.2em;
}
.mode-switch button {
border-radius: 0.55em;
background: transparent;
color: #2f4a63;
padding: 0.45em 0.7em;
font-size: 0.9em;
}
.mode-switch button:hover,
.mode-switch button.active {
background: #f1f3f5;
color: #1c3147;
}
.markdown-workspace {
display: grid;
gap: 1em;
}
.markdown-workspace.split {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.body-field textarea,
.markdown-preview {
min-height: 48vh;
}
.body-field textarea {
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.95em;
}
.markdown-preview {
overflow: auto;
border: 1px solid #e8e5df;
border-radius: 0.7em;
color: #3d3d3f;
font-size: 1em;
line-height: 1.75;
padding: 1em 1.1em;
}
.markdown-preview :first-child {
margin-top: 0;
}
.markdown-preview :last-child {
margin-bottom: 0;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3 {
color: #232428;
line-height: 1.35;
}
.markdown-preview a {
color: #2f4a63;
text-decoration: underline;
text-underline-offset: 0.22em;
}
.markdown-preview img {
display: block;
max-width: min(100%, 42em);
border-radius: 0.7em;
margin: 1.2em auto;
}
.markdown-preview pre {
overflow: auto;
border-radius: 0.7em;
background: #20242b;
color: #f2f2ee;
padding: 1em;
}
.markdown-preview code {
border-radius: 0.35em;
background: #f3f4f6;
padding: 0.12em 0.32em;
}
.markdown-preview pre code {
background: transparent;
padding: 0;
}
.message {
min-height: 1.4em;
margin: 0;
color: #7b4f27;
font-size: 0.9em;
}
.editor-status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
}
.autosave-status {
color: #777b82;
font-size: 0.82em;
white-space: nowrap;
}
.build-panel {
display: grid;
gap: 1em;
border: 1px solid #eee;
border-radius: 0.8em;
background: #fbfaf7;
padding: 1em;
}
.build-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
}
.build-panel h3 {
margin: 0;
}
.build-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8em;
margin: 0;
}
.build-meta div {
min-width: 0;
}
.build-meta dt {
color: #777b82;
font-size: 0.78em;
}
.build-meta dd {
overflow-wrap: anywhere;
margin: 0.25em 0 0;
color: #35383f;
font-size: 0.88em;
}
.build-error {
margin: 0;
color: #9b332c;
font-size: 0.9em;
}
.build-log {
max-height: 22em;
overflow: auto;
margin: 0;
border-radius: 0.7em;
background: #20242b;
color: #f2f2ee;
padding: 1em;
font-size: 0.84em;
line-height: 1.55;
white-space: pre-wrap;
}
@media (max-width: 1100px) {
.topbar {
padding-inline: 8%;
}
.list-view,
.editor-view {
width: 84%;
}
}
@media (max-width: 760px) {
.topbar,
.page-heading,
.editor-head {
align-items: flex-start;
flex-direction: column;
}
.topbar {
padding: 1em 5%;
}
.breadcrumb {
width: 100%;
flex-wrap: wrap;
}
.user-menu {
align-self: flex-end;
}
.list-view,
.editor-view {
width: 90%;
padding-top: 2em;
}
.post-table-head {
display: none;
}
.post-table-row {
grid-template-columns: 1fr;
gap: 0.35em;
}
.page-actions {
width: 100%;
flex-wrap: wrap;
}
.row-actions {
justify-content: flex-start;
}
.fields-grid,
.build-meta,
.markdown-workspace.split {
grid-template-columns: 1fr;
}
.editor-actions,
.editor-status-row {
align-items: flex-start;
flex-direction: column;
}
.editor-actions {
width: 100%;
align-items: stretch;
}
.editor-actions button,
.editor-actions .upload-button {
width: 100%;
}
.autosave-status {
white-space: normal;
}
.body-label-row {
align-items: flex-start;
flex-direction: column;
}
}

View file

@ -0,0 +1,310 @@
@if (loading) {
<main class="loading-shell">载入中</main>
} @else if (!user) {
<main class="login-view">
<form class="login-panel" (ngSubmit)="login()">
<div>
<p class="eyebrow">Osaet Admin</p>
<h1>登录后台</h1>
</div>
<label>
用户名
<input name="username" autocomplete="username" [(ngModel)]="loginUsername" />
</label>
<label>
密码
<input
name="password"
type="password"
autocomplete="current-password"
[(ngModel)]="loginPassword"
/>
</label>
<button type="submit">登录</button>
<p class="message">{{ loginMessage }}</p>
</form>
</main>
} @else {
<main class="shell">
<header class="topbar">
<nav class="breadcrumb" aria-label="面包屑">
<a href="/">首页</a>
<span>/</span>
@if (view === 'list') {
<span>文章</span>
} @else {
<button type="button" (click)="backToList()">文章</button>
<span>/</span>
<span>{{ draft.title || '新文章' }}</span>
}
</nav>
<details class="user-menu">
<summary>{{ user.username }}</summary>
<div class="user-menu-panel">
<button type="button" (click)="logout()">退出</button>
</div>
</details>
</header>
@if (feedback) {
<aside
class="feedback-toast"
[class.info]="feedback.tone === 'info'"
[class.error]="feedback.tone === 'error'"
aria-live="polite"
>
<div>
<strong>{{ feedback.title }}</strong>
<span>{{ feedback.message }}</span>
</div>
<button type="button" aria-label="关闭提示" (click)="closeFeedback()">x</button>
</aside>
}
@if (view === 'list') {
<section class="list-view">
<div class="page-heading">
<div>
<p class="eyebrow">Posts</p>
<h1>文章管理</h1>
</div>
<div class="page-actions">
<select aria-label="文章状态" [(ngModel)]="statusFilter" (change)="changeStatusFilter()">
<option value="">全部</option>
<option value="draft">草稿</option>
<option value="published">已发布</option>
<option value="archived">归档</option>
</select>
<button type="button" (click)="newPost()">新文章</button>
</div>
</div>
<div class="post-table">
@if (posts.length === 0) {
<p class="empty-message">暂无文章</p>
} @else {
<div class="post-table-head">
<span>标题</span>
<span>标签</span>
<span>状态</span>
<span>更新时间</span>
<span>操作</span>
</div>
@for (post of posts; track post.id) {
<div
class="post-table-row"
role="button"
tabindex="0"
(click)="selectPost(post.id)"
>
<span class="post-row-title">{{ post.title || '未命名' }}</span>
<span class="post-row-tags">{{ tagText(post) }}</span>
<span>{{ statusText(post.status) }}</span>
<span>{{ formatDate(post.updatedAt) }}</span>
<span class="row-actions">
<span class="table-action" role="button" tabindex="0" (click)="editPost(post.id, $event)">
编辑
</span>
<span class="table-action danger" role="button" tabindex="0" (click)="deletePost(post, $event)">
删除
</span>
</span>
</div>
}
}
</div>
<nav class="pagination" aria-label="文章分页">
<button type="button" class="link-button" [disabled]="page <= 1" (click)="previousPage()">
← 上一页
</button>
<span class="page-numbers">
<button
type="button"
class="page-number"
*ngFor="let pageNumber of pageNumbers"
[class.active]="pageNumber === page"
(click)="goToPage(pageNumber)"
>
{{ pageNumber }}
</button>
</span>
<button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()">
下一页 →
</button>
</nav>
</section>
} @else {
<section class="editor-view">
<form class="editor-form" (ngSubmit)="savePost()">
<div class="editor-head">
<div>
<p class="eyebrow">{{ currentPost ? '版本 ' + currentPost.version : '新文章' }}</p>
<h1>
{{ draft.title || '开始写作' }}
@if (hasUnsavedChanges()) {
<span class="dirty-dot" title="未保存"></span>
}
</h1>
</div>
<div class="editor-actions">
<label class="upload-button">
{{ uploadingAsset ? '上传中' : '上传图片' }}
<input type="file" accept="image/*" [disabled]="uploadingAsset" (change)="uploadAsset($event)" />
</label>
<button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button>
<button type="button" class="build-button" (click)="buildPost()">构建</button>
</div>
</div>
<div class="fields-grid">
<label>
标题
<input name="title" required [(ngModel)]="draft.title" (input)="onTitleInput()" />
</label>
<label>
Slug
<span class="slug-control">
<input name="slug" required [(ngModel)]="draft.slug" (input)="onSlugInput()" />
<button
type="button"
class="slug-ai-button"
[disabled]="generatingSlug"
[attr.aria-label]="generatingSlug ? '正在生成 Slug' : 'AI 生成 Slug'"
[title]="generatingSlug ? '正在生成 Slug' : 'AI 生成 Slug'"
(click)="generateSlug()"
>
<span aria-hidden="true"></span>
</button>
</span>
</label>
<label>
状态
<select name="status" [(ngModel)]="draft.status" (change)="onDraftInput()">
<option value="draft">草稿</option>
<option value="published">已发布</option>
<option value="archived">归档</option>
</select>
</label>
<label>
创建时间
<input
name="createdAt"
type="datetime-local"
[(ngModel)]="draft.createdAt"
(input)="onDraftInput()"
/>
</label>
<label>
封面
<input name="cover" [(ngModel)]="draft.cover" (input)="onDraftInput()" />
</label>
<label class="wide-field">
标签
<input
name="tags"
placeholder="用逗号分隔,例如:生活, 技术"
[(ngModel)]="tagsText"
(input)="onDraftInput()"
/>
</label>
</div>
<label>
摘要
<textarea
name="summary"
rows="3"
[(ngModel)]="draft.summary"
(input)="onDraftInput()"
></textarea>
</label>
<label class="body-field">
<span class="body-label-row">
正文 Markdown
<span class="mode-switch" role="group" aria-label="编辑模式">
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')">
编辑
</button>
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')">
预览
</button>
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')">
分栏
</button>
</span>
</span>
<div class="markdown-workspace" [class.split]="editorMode === 'split'">
@if (editorMode !== 'preview') {
<textarea
#bodyTextarea
name="bodyMarkdown"
spellcheck="false"
[(ngModel)]="draft.bodyMarkdown"
(input)="onDraftInput()"
></textarea>
}
@if (editorMode !== 'edit') {
<article class="markdown-preview" [innerHTML]="previewHtml"></article>
}
</div>
</label>
<div class="editor-status-row">
<p class="message">{{ editorMessage }}</p>
<span class="autosave-status">{{ autosaveStatus }}</span>
</div>
@if (currentBuildJob) {
<section class="build-panel">
<div class="build-panel-head">
<div>
<p class="eyebrow">Build Job</p>
<h3>{{ buildStatusText(currentBuildJob.status) }}</h3>
</div>
<button type="button" class="link-button" (click)="toggleBuildLog()">
{{ showBuildLog ? '收起日志' : '查看日志' }}
</button>
</div>
<dl class="build-meta">
<div>
<dt>ID</dt>
<dd>{{ currentBuildJob.id }}</dd>
</div>
<div>
<dt>开始</dt>
<dd>{{ formatDate(currentBuildJob.startedAt) }}</dd>
</div>
<div>
<dt>结束</dt>
<dd>{{ formatDate(currentBuildJob.finishedAt) }}</dd>
</div>
</dl>
@if (currentBuildJob.error) {
<p class="build-error">{{ currentBuildJob.error }}</p>
}
@if (showBuildLog) {
<pre class="build-log">{{ currentBuildJob.log || '暂无日志' }}</pre>
}
</section>
}
</form>
</section>
}
</main>
}

View file

@ -0,0 +1,790 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs';
import { AdminApiService } from './admin-api.service';
import { BuildJob, Post, PostInput, PostStatus, User } from './models';
type FeedbackTone = 'success' | 'info' | 'error';
@Component({
selector: 'osaet-root',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit, OnDestroy {
private readonly api = inject(AdminApiService);
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
user: User | null = null;
posts: Post[] = [];
currentPost: Post | null = null;
statusFilter: PostStatus | '' = '';
view: 'list' | 'editor' = 'list';
editorMode: 'edit' | 'preview' | 'split' = 'edit';
page = 1;
readonly pageSize = 12;
totalPosts = 0;
loginUsername = 'yarnom';
loginPassword = '';
loginMessage = '';
editorMessage = '';
feedback: { title: string; message: string; tone: FeedbackTone } | null = null;
currentBuildJob: BuildJob | null = null;
showBuildLog = false;
loading = true;
saving = false;
uploadingAsset = false;
generatingSlug = false;
autosaveStatus = '未修改';
lastAutosavedAt: Date | null = null;
draft = this.emptyDraft();
tagsText = '';
private savedSnapshot = '';
private readonly autosaveIntervalMs = 12000;
private autosaveSubscription: Subscription | null = null;
private feedbackTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit() {
void this.bootstrap();
this.autosaveSubscription = interval(this.autosaveIntervalMs).subscribe(() => {
void this.autosave();
});
}
ngOnDestroy() {
this.autosaveSubscription?.unsubscribe();
if (this.feedbackTimer) {
clearTimeout(this.feedbackTimer);
}
}
async bootstrap() {
try {
const response = await firstValueFrom(this.api.me());
this.user = response.user;
await this.loadPosts();
} catch {
this.user = null;
} finally {
this.loading = false;
this.rememberSavedState();
this.updateAutosaveStatus();
}
}
async login() {
this.loginMessage = '';
try {
const response = await firstValueFrom(this.api.login(this.loginUsername.trim(), this.loginPassword));
this.user = response.user;
this.loginPassword = '';
await this.loadPosts();
this.newPost();
} catch (error) {
this.loginMessage = errorMessage(error);
}
}
async logout() {
if (!this.confirmDiscard()) {
return;
}
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
this.user = null;
this.posts = [];
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
this.rememberSavedState();
this.updateAutosaveStatus();
}
async loadPosts() {
const response = await firstValueFrom(
this.api.listPosts(this.statusFilter, this.pageSize, (this.page - 1) * this.pageSize)
);
this.posts = response.posts ?? [];
this.totalPosts = response.total ?? 0;
if (this.page > this.totalPages) {
this.page = this.totalPages;
await this.loadPosts();
}
}
async selectPost(id: string) {
if (!this.confirmDiscard()) {
return;
}
const response = await firstValueFrom(this.api.getPost(id));
this.currentPost = response.post;
this.draft = this.postToInput(response.post);
this.tagsText = this.draft.tags.join(', ');
this.editorMessage = '';
this.currentBuildJob = null;
this.showBuildLog = false;
this.rememberSavedState();
this.updateAutosaveStatus();
this.view = 'editor';
}
editPost(id: string, event: Event) {
event.stopPropagation();
void this.selectPost(id);
}
async deletePost(post: Post, event: Event) {
event.stopPropagation();
if (!window.confirm(`确定删除《${post.title || '未命名'}》吗?`)) {
return;
}
try {
const response = await firstValueFrom(this.api.deletePost(post.id));
if (this.currentPost?.id === post.id) {
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
this.view = 'list';
this.rememberSavedState();
this.updateAutosaveStatus();
}
await this.loadPosts();
if (response.buildJob) {
this.currentBuildJob = response.buildJob;
this.editorMessage = '已删除,正在重新构建站点';
this.watchBuildJob(response.buildJob);
}
} catch (error) {
this.editorMessage = errorMessage(error);
}
}
newPost() {
if (!this.confirmDiscard()) {
return;
}
this.currentPost = null;
this.draft = this.emptyDraft();
this.tagsText = '';
this.editorMessage = '';
this.currentBuildJob = null;
this.showBuildLog = false;
this.rememberSavedState();
this.updateAutosaveStatus();
this.view = 'editor';
}
async backToList() {
if (!this.confirmDiscard()) {
return;
}
this.view = 'list';
await this.loadPosts();
}
async changeStatusFilter() {
this.page = 1;
await this.loadPosts();
}
async previousPage() {
if (this.page <= 1) {
return;
}
this.page -= 1;
await this.loadPosts();
}
async nextPage() {
if (this.page >= this.totalPages) {
return;
}
this.page += 1;
await this.loadPosts();
}
async goToPage(page: number) {
if (page < 1 || page > this.totalPages || page === this.page) {
return;
}
this.page = page;
await this.loadPosts();
}
async savePost(silent = false) {
this.saving = true;
if (!silent) {
this.editorMessage = '';
}
if (silent) {
this.autosaveStatus = '自动保存中';
}
try {
const input = this.normalizedDraft();
const response = this.currentPost
? await firstValueFrom(this.api.updatePost(this.currentPost.id, input))
: await firstValueFrom(this.api.createPost(input));
this.currentPost = response.post;
this.draft = this.postToInput(response.post);
this.tagsText = this.draft.tags.join(', ');
await this.loadPosts();
if (!silent) {
this.editorMessage = '已保存';
this.showFeedback('保存成功', '文章内容已经保存。');
this.lastAutosavedAt = null;
}
if (silent) {
this.lastAutosavedAt = new Date();
}
this.rememberSavedState();
this.updateAutosaveStatus();
return response;
} catch (error) {
if (silent) {
this.autosaveStatus = `自动保存失败:${errorMessage(error)}`;
} else {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('保存失败', message, 'error');
}
return null;
} finally {
this.saving = false;
}
}
async buildPost() {
const saved = await this.savePost();
if (!saved) {
return;
}
if (!this.currentPost) {
return;
}
this.editorMessage = '';
try {
const response = await firstValueFrom(this.api.buildPost(this.currentPost.id));
this.editorMessage = '已开始构建';
this.showFeedback('构建已提交', '正在按照当前状态生成静态站点。', 'info');
this.currentBuildJob = response.buildJob;
this.showBuildLog = false;
this.rememberSavedState();
this.updateAutosaveStatus();
this.watchBuildJob(response.buildJob);
} catch (error) {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('构建失败', message, 'error');
}
}
watchBuildJob(job: BuildJob, notify = true) {
interval(1400)
.pipe(
switchMap(() => this.api.getBuildJob(job.id)),
takeWhile((response) => {
const status = response.buildJob.status;
this.currentBuildJob = response.buildJob;
this.editorMessage =
status === 'failed' && response.buildJob.error
? `构建失败:${response.buildJob.error}`
: `构建状态:${buildStatusText(status)}`;
if (!notify) {
return !['success', 'failed', 'cancelled'].includes(status);
}
if (status === 'success') {
this.showFeedback('构建完成', '静态页面已经更新。');
} else if (status === 'failed') {
this.showFeedback('构建失败', response.buildJob.error || '请查看构建日志。', 'error');
} else if (status === 'cancelled') {
this.showFeedback('构建已取消', '本次构建没有完成。', 'error');
}
return !['success', 'failed', 'cancelled'].includes(status);
}, true),
catchError((error) => {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('构建状态获取失败', message, 'error');
return of(null);
})
)
.subscribe();
}
onTitleInput() {
if (!this.currentPost && !this.draft.slug.trim()) {
this.draft.slug = slugify(this.draft.title);
this.draft.slugSource = 'title';
this.draft.slugLocked = false;
}
this.updateAutosaveStatus();
}
onSlugInput() {
this.draft.slugSource = 'manual';
this.draft.slugLocked = true;
this.updateAutosaveStatus();
}
hasUnsavedChanges() {
return this.editorSnapshot() !== this.savedSnapshot;
}
@HostListener('window:beforeunload', ['$event'])
beforeUnload(event: BeforeUnloadEvent) {
if (this.hasUnsavedChanges()) {
event.preventDefault();
event.returnValue = '';
}
}
@HostListener('window:keydown', ['$event'])
handleKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (this.user && !this.saving) {
void this.savePost();
}
}
}
onDraftInput() {
this.updateAutosaveStatus();
}
statusText(status: PostStatus) {
return statusText(status);
}
formatDate(value?: string | null) {
if (!value) {
return '无时间';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value));
}
tagText(post: Post) {
return post.tags?.length ? post.tags.join(' / ') : '无标签';
}
buildStatusText(status: string) {
return buildStatusText(status);
}
toggleBuildLog() {
this.showBuildLog = !this.showBuildLog;
}
setEditorMode(mode: 'edit' | 'preview' | 'split') {
this.editorMode = mode;
}
closeFeedback() {
this.feedback = null;
if (this.feedbackTimer) {
clearTimeout(this.feedbackTimer);
this.feedbackTimer = null;
}
}
get previewHtml() {
return renderMarkdown(this.draft.bodyMarkdown);
}
async uploadAsset(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
this.uploadingAsset = true;
this.editorMessage = '正在上传图片';
try {
const response = await firstValueFrom(this.api.uploadAsset(file));
this.insertMarkdown(`![${altText(file.name)}](${response.asset.path})`);
this.editorMessage = '图片已插入';
this.updateAutosaveStatus();
} catch (error) {
this.editorMessage = errorMessage(error);
} finally {
this.uploadingAsset = false;
}
}
async generateSlug() {
const title = this.draft.title.trim();
if (!title) {
this.showFeedback('无法生成 Slug', '请先填写标题。', 'error');
return;
}
this.generatingSlug = true;
this.editorMessage = '正在生成 Slug';
try {
const response = await firstValueFrom(
this.api.generateSlug(title, this.draft.summary.trim(), this.currentPost?.id)
);
this.draft.slug = response.slug;
this.draft.slugSource = 'ai';
this.draft.slugLocked = false;
this.editorMessage = 'Slug 已生成';
this.showFeedback('Slug 已生成', response.slug);
this.updateAutosaveStatus();
} catch (error) {
const message = errorMessage(error);
this.editorMessage = message;
this.showFeedback('Slug 生成失败', message, 'error');
} finally {
this.generatingSlug = false;
}
}
get totalPages() {
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
}
get pageNumbers() {
return Array.from({ length: this.totalPages }, (_, index) => index + 1);
}
private async autosave() {
if (!this.canAutosave()) {
return;
}
await this.savePost(true);
}
private canAutosave() {
const input = this.normalizedDraft();
return Boolean(
this.user &&
!this.saving &&
this.hasUnsavedChanges() &&
input.title &&
input.slug
);
}
private normalizedDraft(): PostInput {
return {
...this.draft,
title: this.draft.title.trim(),
slug: this.draft.slug.trim(),
summary: this.draft.summary.trim(),
cover: this.draft.cover.trim(),
tags: parseTags(this.tagsText),
createdAt: datetimeLocalToIso(this.draft.createdAt)
};
}
private postToInput(post: Post): PostInput {
return {
slug: post.slug,
title: post.title,
summary: post.summary,
bodyMarkdown: post.bodyMarkdown,
status: post.status === 'deleted' ? 'draft' : post.status,
tags: [...(post.tags ?? [])],
cover: post.cover,
slugSource: post.slugSource || 'manual',
slugLocked: post.slugLocked,
createdAt: toDateTimeLocal(post.createdAt)
};
}
private emptyDraft(): PostInput {
return {
slug: '',
title: '',
summary: '',
bodyMarkdown: '',
status: 'draft',
tags: [],
cover: '',
slugSource: 'manual',
slugLocked: true,
createdAt: toDateTimeLocal(new Date().toISOString())
};
}
private confirmDiscard() {
if (!this.hasUnsavedChanges()) {
return true;
}
return window.confirm('当前文章有未保存的修改,确定要离开吗?');
}
private rememberSavedState() {
this.savedSnapshot = this.editorSnapshot();
}
private updateAutosaveStatus() {
if (this.saving) {
return;
}
if (this.hasUnsavedChanges()) {
this.autosaveStatus = '有未保存修改';
return;
}
if (this.lastAutosavedAt) {
this.autosaveStatus = `已自动保存 ${this.formatDate(this.lastAutosavedAt.toISOString())}`;
return;
}
this.autosaveStatus = '已保存';
}
private editorSnapshot() {
return JSON.stringify({
...this.draft,
tags: parseTags(this.tagsText)
});
}
private insertMarkdown(markdown: string) {
const textarea = this.bodyTextarea?.nativeElement;
const current = this.draft.bodyMarkdown ?? '';
if (!textarea) {
this.draft.bodyMarkdown = current ? `${current}\n\n${markdown}\n` : `${markdown}\n`;
return;
}
const start = textarea.selectionStart ?? current.length;
const end = textarea.selectionEnd ?? current.length;
const prefix = current.slice(0, start);
const suffix = current.slice(end);
const before = prefix && !prefix.endsWith('\n') ? '\n\n' : '';
const after = suffix && !suffix.startsWith('\n') ? '\n\n' : '\n';
this.draft.bodyMarkdown = `${prefix}${before}${markdown}${after}${suffix}`;
requestAnimationFrame(() => {
textarea.focus();
const cursor = start + before.length + markdown.length + after.length;
textarea.setSelectionRange(cursor, cursor);
});
}
private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') {
this.feedback = { title, message, tone };
if (this.feedbackTimer) {
clearTimeout(this.feedbackTimer);
}
this.feedbackTimer = setTimeout(() => {
this.feedback = null;
this.feedbackTimer = null;
}, 3200);
}
}
function normalizeTags(tags: string[]) {
const seen = new Set<string>();
return tags
.map((tag) => tag.trim())
.filter((tag) => {
const key = tag.toLowerCase();
if (!tag || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function parseTags(value: string) {
return normalizeTags(value.split(/[,]/));
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[\s_]+/g, '-')
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function altText(filename: string) {
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
}
function toDateTimeLocal(value?: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return localDate.toISOString().slice(0, 16);
}
function datetimeLocalToIso(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
function renderMarkdown(markdown: string) {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const html: string[] = [];
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] = [];
let inCode = false;
const flushParagraph = () => {
if (paragraph.length === 0) {
return;
}
html.push(`<p>${inlineMarkdown(paragraph.join(' '))}</p>`);
paragraph = [];
};
const flushList = () => {
if (list.length === 0) {
return;
}
html.push(`<ul>${list.map((item) => `<li>${inlineMarkdown(item)}</li>`).join('')}</ul>`);
list = [];
};
const flushCode = () => {
html.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
code = [];
};
for (const line of lines) {
if (line.trim().startsWith('```')) {
if (inCode) {
flushCode();
inCode = false;
} else {
flushParagraph();
flushList();
inCode = true;
}
continue;
}
if (inCode) {
code.push(line);
continue;
}
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
continue;
}
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
html.push(`<h${level}>${inlineMarkdown(heading[2])}</h${level}>`);
continue;
}
if (/^[-*_]{3,}$/.test(trimmed)) {
flushParagraph();
flushList();
html.push('<hr>');
continue;
}
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
if (listItem) {
flushParagraph();
list.push(listItem[1]);
continue;
}
flushList();
paragraph.push(trimmed);
}
if (inCode) {
flushCode();
}
flushParagraph();
flushList();
return html.join('\n');
}
function inlineMarkdown(value: string) {
return escapeHtml(value)
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<img src="$2" alt="$1">')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function statusText(status: PostStatus) {
return (
{
draft: '草稿',
published: '已发布',
archived: '归档',
deleted: '已删除'
} satisfies Record<PostStatus, string>
)[status];
}
function buildStatusText(status: string) {
return (
{
queued: '等待中',
running: '构建中',
success: '成功',
failed: '失败',
cancelled: '已取消'
} as Record<string, string>
)[status] ?? status;
}
function errorMessage(error: unknown) {
if (typeof error === 'object' && error && 'error' in error) {
const body = (error as { error?: { error?: string } }).error;
if (body?.error) {
return body.error;
}
}
if (error instanceof Error) {
return error.message;
}
return '请求失败';
}

View file

@ -0,0 +1,97 @@
export type PostStatus = 'draft' | 'published' | 'archived' | 'deleted';
export type BuildJobStatus = 'queued' | 'running' | 'success' | 'failed' | 'cancelled';
export type User = {
id: string;
username: string;
createdAt: string;
updatedAt: string;
lastLoginAt?: string | null;
};
export type Post = {
id: string;
slug: string;
title: string;
summary: string;
bodyMarkdown: string;
status: PostStatus;
tags: string[];
cover: string;
version: number;
slugSource: string;
slugLocked: boolean;
publishedAt?: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
};
export type PostInput = {
slug: string;
title: string;
summary: string;
bodyMarkdown: string;
status: PostStatus;
tags: string[];
cover: string;
slugSource: string;
slugLocked: boolean;
createdAt?: string | null;
};
export type BuildJob = {
id: string;
trigger: string;
status: BuildJobStatus;
postId?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
log: string;
error: string;
createdAt: string;
createdBy?: string | null;
};
export type Asset = {
id: string;
path: string;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
createdAt: string;
createdBy?: string | null;
};
export type LoginResponse = {
user: User;
expiresAt: string;
};
export type PostResponse = {
post: Post;
buildJob?: BuildJob | null;
};
export type PostsResponse = {
posts: Post[];
total: number;
};
export type BuildJobResponse = {
buildJob: BuildJob;
};
export type AssetResponse = {
asset: Asset;
};
export type SlugResponse = {
slug: string;
};
export type DeletePostResponse = {
ok: boolean;
buildJob?: BuildJob | null;
};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Osaet Admin</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<osaet-root></osaet-root>
</body>
</html>

View file

@ -0,0 +1,9 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideHttpClient(withFetch()), provideAnimations()]
}).catch((error) => console.error(error));

View file

@ -0,0 +1,29 @@
:root {
color: #24262b;
background: #f7f6f2;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
"Source Han Sans SC", "Microsoft YaHei", sans-serif;
font-size: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: #f7f6f2;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 0;
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View file

@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View file

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View file

@ -0,0 +1,177 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import { tagSlug, type ArchiveYear } from '../lib/posts';
type Props = {
archiveYears: ArchiveYear[];
};
const { archiveYears } = Astro.props;
---
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>Archive</h1>
</header>
{archiveYears.length === 0 ? (
<p class="empty">No published posts yet.</p>
) : (
<div class="archive-list">
{archiveYears.map((group) => (
<section class="archive-year" aria-label={group.year}>
<h2>{group.year}</h2>
<ol>
{group.posts.map((post) => (
<li>
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language, {
month: '2-digit',
day: '2-digit'
})}
</time>
)}
<a href={post.url}>{post.title}</a>
{post.tags.length > 0 && (
<ul class="tags compact-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</li>
))}
</ol>
</section>
))}
</div>
)}
</main>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.archive-year li > a,
.tags a {
color: inherit;
text-decoration: none;
}
.page :global(.site-nav a:hover) {
text-decoration: underline;
text-underline-offset: 4px;
}
.site-header {
border-bottom: 1px solid #d8d1c3;
padding-bottom: 28px;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6f675b;
font-size: 0.875rem;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 8vw, 5rem);
line-height: 1;
}
.archive-list {
display: grid;
gap: 32px;
}
.archive-year h2 {
margin: 0 0 12px;
font-size: 1.4rem;
}
.archive-year ol {
display: grid;
gap: 12px;
padding: 0;
margin: 0;
list-style: none;
}
.archive-year li {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
border-bottom: 1px solid #ded8cd;
padding: 10px 0;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
padding: 0;
margin: 0;
list-style: none;
}
.tags li {
border: 1px solid #d0c8bb;
border-radius: 999px;
color: #5f584d;
font-size: 0.82rem;
}
.tags a {
display: inline-block;
padding: 2px 8px;
}
.empty {
color: #5b554d;
}
@media (max-width: 640px) {
.archive-year li {
grid-template-columns: 1fr;
gap: 6px;
}
.tags {
justify-content: flex-start;
}
}
</style>

View file

@ -0,0 +1,165 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import { tagSlug, type Post } from '../lib/posts';
type Props = {
posts: Post[];
};
const { posts } = Astro.props;
---
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.description}</p>
<h1>{site.title}</h1>
</header>
<section class="post-list" aria-label="Posts">
{posts.length === 0 ? (
<p class="empty">No published posts yet.</p>
) : (
posts.map((post) => (
<article class="post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.summary && <p>{post.summary}</p>}
<div class="post-meta">
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
{post.tags.length > 0 && (
<ul class="tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</div>
</a>
</article>
))
)}
</section>
</main>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.post-item a,
.tags a {
color: inherit;
text-decoration: none;
}
.page :global(.site-nav a:hover) {
text-decoration: underline;
text-underline-offset: 4px;
}
.site-header {
border-bottom: 1px solid #d8d1c3;
padding-bottom: 28px;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6f675b;
font-size: 0.875rem;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 8vw, 5rem);
line-height: 1;
}
.post-list {
display: grid;
gap: 16px;
}
.post-item {
border-bottom: 1px solid #ded8cd;
padding: 18px 0 22px;
}
.post-item a {
display: block;
}
.post-item h2 {
margin: 0 0 8px;
font-size: 1.45rem;
}
.post-item p,
.empty {
color: #5b554d;
}
.post-item p {
margin: 0 0 12px;
line-height: 1.7;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
.post-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
align-items: center;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 0;
list-style: none;
}
.tags li {
border: 1px solid #d0c8bb;
border-radius: 999px;
color: #5f584d;
font-size: 0.82rem;
}
.tags a {
display: inline-block;
padding: 2px 8px;
}
</style>

View file

@ -0,0 +1,130 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import { tagSlug } from '../lib/posts';
type Props = {
post: any;
};
const { post } = Astro.props;
const { Content } = post;
const title = post.frontmatter.title;
const date = post.frontmatter.created_at ?? post.frontmatter.published_at ?? post.frontmatter.updated_at;
---
<main class="page article-page">
<SiteNav />
<article class="article">
<header>
<h1>{title}</h1>
{date && (
<time datetime={date}>
{new Date(date).toLocaleDateString(site.language)}
</time>
)}
{post.frontmatter.summary && <p class="summary">{post.frontmatter.summary}</p>}
{post.frontmatter.tags?.length > 0 && (
<ul class="tags article-tags" aria-label="Tags">
{post.frontmatter.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</header>
<Content />
</article>
</main>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.tags a {
color: inherit;
text-decoration: none;
}
.page :global(.site-nav a:hover) {
text-decoration: underline;
text-underline-offset: 4px;
}
.article {
line-height: 1.8;
}
.article header {
border-bottom: 1px solid #d8d1c3;
margin-bottom: 32px;
padding-bottom: 24px;
}
.article h1 {
margin: 0 0 16px;
font-size: clamp(2.1rem, 7vw, 4rem);
}
.summary {
color: #5b554d;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0;
margin: 16px 0 0;
list-style: none;
}
.tags li {
border: 1px solid #d0c8bb;
border-radius: 999px;
color: #5f584d;
font-size: 0.82rem;
}
.tags a {
display: inline-block;
padding: 2px 8px;
}
.article :global(pre) {
overflow-x: auto;
border-radius: 8px;
padding: 16px;
background: #272822;
color: #f8f8f2;
}
.article :global(code) {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
</style>

View file

@ -0,0 +1,118 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import type { Post, TagSummary } from '../lib/posts';
type Props = {
tag: TagSummary;
posts: Post[];
};
const { tag, posts } = Astro.props;
---
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>{tag.name}</h1>
</header>
<section class="post-list" aria-label={`Posts tagged ${tag.name}`}>
{posts.map((post) => (
<article class="post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.summary && <p>{post.summary}</p>}
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
</a>
</article>
))}
</section>
</main>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.post-item a {
color: inherit;
text-decoration: none;
}
.page :global(.site-nav a:hover) {
text-decoration: underline;
text-underline-offset: 4px;
}
.site-header {
border-bottom: 1px solid #d8d1c3;
padding-bottom: 28px;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6f675b;
font-size: 0.875rem;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 8vw, 5rem);
line-height: 1;
}
.post-list {
display: grid;
gap: 16px;
}
.post-item {
border-bottom: 1px solid #ded8cd;
padding: 18px 0 22px;
}
.post-item a {
display: block;
}
.post-item h2 {
margin: 0 0 8px;
font-size: 1.45rem;
}
.post-item p {
margin: 0 0 12px;
color: #5b554d;
line-height: 1.7;
}
time {
color: #7a7268;
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,111 @@
---
import SiteNav from './SiteNav.astro';
import { site } from '../lib/siteConfig';
import type { TagSummary } from '../lib/posts';
type Props = {
tags: TagSummary[];
};
const { tags } = Astro.props;
---
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>Tags</h1>
</header>
{tags.length === 0 ? (
<p class="empty">No tags yet.</p>
) : (
<ul class="tag-index" aria-label="Tags">
{tags.map((tag) => (
<li>
<a href={`/tags/${tag.slug}/`}>
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</a>
</li>
))}
</ul>
)}
</main>
<style>
.page {
width: min(860px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0 80px;
color: #20201d;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
.page :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-bottom: 36px;
color: #625a50;
font-size: 0.95rem;
}
.page :global(.site-nav a),
.tag-index a {
color: inherit;
text-decoration: none;
}
.page :global(.site-nav a:hover) {
text-decoration: underline;
text-underline-offset: 4px;
}
.site-header {
border-bottom: 1px solid #d8d1c3;
padding-bottom: 28px;
margin-bottom: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #6f675b;
font-size: 0.875rem;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2.4rem, 8vw, 5rem);
line-height: 1;
}
.tag-index {
display: grid;
gap: 10px;
padding: 0;
margin: 0;
list-style: none;
}
.tag-index a {
display: flex;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #ded8cd;
padding: 14px 0;
}
.tag-index strong {
color: #766f65;
font-weight: 500;
}
.empty {
color: #5b554d;
}
</style>

View file

@ -1,8 +1,8 @@
---
const links = [
{ href: '/', label: 'Home' },
{ href: '/archive/', label: 'Archive' },
{ href: '/tags/', label: 'Tags' },
{ href: '/', label: '首页' },
{ href: '/archive/', label: '归档' },
{ href: '/tags/', label: '标签' },
{ href: '/rss.xml', label: 'RSS' }
];
---

View file

@ -0,0 +1,346 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import { tagSlug, type ArchiveYear } from '../../../lib/posts';
type Props = {
archiveYears: ArchiveYear[];
};
const { archiveYears } = Astro.props;
---
<main class="yar-archive-page">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-archive" aria-label="Archive">
{archiveYears.length === 0 ? (
<p class="yar-empty">No published posts yet.</p>
) : (
<div class="yar-archive-list">
{archiveYears.map((group) => (
<section class="yar-archive-year" aria-label={group.year}>
<div class="yar-archive-year-label">
<div class="yar-archive-year-meta">
<h3>{group.year}</h3>
<p>共 {group.posts.length} 篇</p>
</div>
</div>
<ol>
{group.posts.map((post) => (
<li>
<time datetime={post.date}>
{post.date
? new Date(post.date).toLocaleDateString(site.language, {
month: '2-digit',
day: '2-digit'
})
: '----'}
</time>
<a class="yar-archive-post" href={post.url}>{post.title}</a>
{post.tags.length > 0 && (
<ul class="yar-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</li>
))}
</ol>
</section>
))}
</div>
)}
</section>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-archive-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-archive {
width: 100%;
margin: 0;
padding: 36px 0 0;
color: #3d3d3f;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
letter-spacing: 1px;
line-height: 1.7em;
}
.yar-archive-list {
display: grid;
gap: 3.2em 0;
}
.yar-archive-year {
display: grid;
grid-template-columns: 25% 50% 25%;
}
.yar-archive-year-label {
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
}
.yar-archive-year-meta {
display: grid;
gap: 0.35em;
align-content: start;
justify-items: center;
}
.yar-archive-year h3 {
margin: 0;
color: #8a9089;
font-size: 1em;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1.7em;
}
.yar-archive-year-label p {
margin: 0;
color: #b0b5ae;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.72rem;
line-height: 1.4;
}
.yar-archive-year ol {
display: grid;
gap: 0.8em;
grid-column: 2;
padding: 0;
margin: 0;
list-style: none;
}
.yar-archive-year > ol > li {
display: grid;
grid-template-columns: 5.5em minmax(0, 1fr) auto;
gap: 1em;
align-items: flex-start;
border-bottom: 0;
padding: 0;
}
.yar-archive-year time {
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.7em;
white-space: nowrap;
}
.yar-archive-post {
color: #1c211b;
line-height: 1.5;
text-decoration: none;
}
.yar-archive-post:hover {
color: #1d3557;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 32%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-tags {
display: flex;
flex-wrap: wrap;
gap: 0.45em;
justify-content: flex-end;
padding: 0;
margin: 0;
list-style: none;
}
.yar-tags li {
display: inline-flex;
}
.yar-tags a {
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.72rem;
line-height: 1;
padding: 0.5em 0.75em;
text-decoration: none;
}
.yar-tags a:hover {
background: #e8eee9;
}
.yar-empty {
margin: 0;
color: #596254;
text-align: center;
}
.yar-footer {
width: 50vw;
margin: 2em auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.yar-archive {
width: calc(100% - 28px);
margin: 0 auto;
}
.yar-archive-year {
grid-template-columns: 1fr;
gap: 0.8em;
}
.yar-archive-year-label {
justify-content: flex-start;
padding-right: 0;
}
.yar-archive-year ol {
grid-column: auto;
}
.yar-archive-year > ol > li {
grid-template-columns: 1fr;
gap: 0.55em;
}
.yar-tags {
justify-content: flex-start;
}
}
</style>

View file

@ -0,0 +1,476 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import { tagSlug, type Post } from '../../../lib/posts';
type Props = {
posts: Post[];
currentPage?: number;
totalPages?: number;
};
const { posts, currentPage = 1, totalPages = 1 } = Astro.props;
const pages = Array.from({ length: totalPages }, (_, index) => index + 1);
function pageHref(page: number): string {
return page === 1 ? '/' : `/page/${page}/`;
}
function formatPublishedAt(date: string): string {
return new Date(date).toLocaleString(site.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
---
<main class="yar-home">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-section" aria-label="Posts">
<div class="yar-posts">
{posts.length === 0 ? (
<p class="yar-empty">No published posts yet.</p>
) : (
posts.map((post) => {
const Content = post.Content;
return (
<article class="yar-post">
<header class="yar-post-header">
<a href={post.url}>
<div class="heti heti--sans">
<h2 class="post-title">{post.title}</h2>
</div>
</a>
{post.date && (
<time class="yar-date" datetime={post.date}>
发布时间:{formatPublishedAt(post.date)}
</time>
)}
</header>
<div class="yar-post-content heti heti--sans">
<Content />
</div>
{post.tags.length > 0 && (
<ul class="yar-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</article>
);
})
)}
</div>
</section>
{totalPages > 1 && (
<nav class="yar-pagination" aria-label="Pagination">
{currentPage > 1 && (
<a href={pageHref(currentPage - 1)} aria-label="上一页">上一页</a>
)}
<ol>
{pages.map((page) => (
<li>
<a
href={pageHref(page)}
class:list={['yar-page-link', { 'is-current': page === currentPage }]}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</a>
</li>
))}
</ol>
{currentPage < totalPages && (
<a href={pageHref(currentPage + 1)} aria-label="下一页">下一页</a>
)}
</nav>
)}
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-home {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.heti {
max-width: 42em;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
line-height: 1.9;
letter-spacing: 0;
overflow-wrap: break-word;
text-align: justify;
}
.heti h1,
.heti h2,
.heti h3,
.heti h4,
.heti h5,
.heti h6 {
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-weight: 700;
line-height: 1.35;
text-align: start;
}
.heti--sans {
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
}
.heti a {
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
}
.heti img {
max-width: 100%;
height: auto;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-section {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
}
.yar-posts {
display: grid;
gap: 0;
}
.yar-post {
color: #3d3d3f;
font-size: 16px;
line-height: 1.7em;
border-bottom: 2px dashed #eee;
letter-spacing: 1px;
padding-bottom: 1.5em;
margin-bottom: 3.5em;
}
.yar-post + .yar-post {
padding-top: 0;
}
.yar-post-header a {
display: inline-block;
color: inherit;
text-decoration: none;
}
.post-title {
margin: 0 0 8px;
line-height: 1.2;
}
.yar-post-content {
margin-top: 12px;
}
.yar-post-content :global(p:first-child) {
margin-top: 0;
}
.yar-post-content :global(p:last-child) {
margin-bottom: 0;
}
.yar-post-content :global(img) {
display: block;
border-radius: 0.7em;
max-width: 100%;
height: auto;
margin: 1.2em auto;
}
.yar-post-content :global(hr) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border: 0;
margin: 2em 0;
color: #0003;
font-size: 1.1em;
text-align: center;
}
.yar-post-content :global(hr::before) {
content: "* * *";
}
.yar-post-content :global(pre) {
overflow-x: auto;
border-radius: 0.8em;
background: #f6f8fa;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 8%);
color: #263238;
font-size: 0.92em;
line-height: 1.7;
margin: 1.4em 0;
padding: 1em 1.2em;
}
.yar-post-content :global(pre code) {
background: transparent;
color: inherit;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: inherit;
line-height: inherit;
padding: 0;
}
.yar-post-content :global(:not(pre) > code) {
border-radius: 0.35em;
background: #f3f6f4;
color: #1d3557;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
padding: 0.15em 0.35em;
}
.yar-post-content :global(a) {
color: #1d3557;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 24%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-post-content :global(a:hover) {
background: #f3f6f4;
text-decoration-color: rgb(29 53 87 / 48%);
}
.yar-post p {
margin: 0;
color: #596254;
line-height: 1.8;
}
.yar-date {
display: block;
margin-top: 0.45em;
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
white-space: nowrap;
}
.yar-tags {
display: flex;
flex-wrap: wrap;
gap: 0.55em;
padding: 1em 0 0;
margin: 0;
list-style: none;
}
.yar-tags li {
display: inline-flex;
}
.yar-tags a {
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-tags a:hover {
background: #e8eee9;
}
.yar-empty {
margin: 0;
color: #596254;
}
.yar-pagination {
display: flex;
justify-content: center;
gap: 0.7em;
align-items: center;
width: 50vw;
margin: 0 auto 3.5em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-pagination ol {
display: flex;
gap: 0.45em;
padding: 0;
margin: 0;
list-style: none;
}
.yar-pagination a {
border-radius: 0.7em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-pagination a:hover,
.yar-pagination .is-current {
background: #eef1ed;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-section,
.yar-pagination,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.yar-date {
white-space: normal;
}
}
</style>

View file

@ -0,0 +1,331 @@
---
import { site } from '../../../lib/siteConfig';
import { tagSlug } from '../../../lib/posts';
type Props = {
post: any;
};
const { post } = Astro.props;
const { Content } = post;
const date = post.frontmatter.created_at ?? post.frontmatter.published_at ?? post.frontmatter.updated_at;
function formatPublishedAt(value: string): string {
return new Date(value).toLocaleString(site.language, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
---
<main class="yar-page">
<div class="yar-shell">
<div class="yar-back-row">
<a class="yar-back" href="/" data-history-back>← Back</a>
</div>
<article class="yar-article">
<header class="yar-article-header">
<div class="heti heti--sans">
<h1 class="post-title">{post.frontmatter.title}</h1>
</div>
{date && (
<time class="yar-date" datetime={date}>
{formatPublishedAt(date)}
</time>
)}
{post.frontmatter.tags?.length > 0 && (
<ul class="yar-tags" aria-label="Tags">
{post.frontmatter.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</header>
<div class="yar-post-content heti heti--sans">
<Content />
</div>
</article>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-back-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1em;
width: 50vw;
margin: 0 auto;
}
.yar-back {
color: #1d3557;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 24%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-back:hover {
background: #f3f6f4;
text-decoration-color: rgb(29 53 87 / 48%);
}
.heti,
.heti--sans {
max-width: 42em;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
line-height: 1.9;
letter-spacing: 0;
overflow-wrap: break-word;
text-align: justify;
}
.heti h1,
.heti h2,
.heti h3,
.heti h4,
.heti h5,
.heti h6 {
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-weight: 700;
line-height: 1.35;
text-align: start;
}
.yar-article {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
color: #3d3d3f;
font-size: 16px;
line-height: 1.7em;
letter-spacing: 1px;
}
.yar-article-header {
text-align: center;
}
.yar-article-header .heti {
max-width: none;
text-align: center;
}
.yar-article-header .post-title {
margin: 0 0 8px;
font-size: 1.75em;
letter-spacing: 0.06em;
line-height: 1.2;
text-align: center;
}
.yar-date {
display: block;
margin: 0.45em 0 2.4em;
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
text-align: center;
white-space: nowrap;
}
.yar-tags {
display: flex;
flex-wrap: wrap;
gap: 0.55em;
padding: 1em 0 0;
margin: 0;
list-style: none;
}
.yar-tags li {
display: inline-flex;
}
.yar-tags a {
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-tags a:hover {
background: #e8eee9;
}
.yar-post-content :global(p:first-child) {
margin-top: 0;
}
.yar-post-content :global(p:last-child) {
margin-bottom: 0;
}
.yar-post-content :global(img) {
display: block;
border-radius: 0.7em;
max-width: 100%;
height: auto;
margin: 1.2em auto;
}
.yar-post-content :global(hr) {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
border: 0;
margin: 2em 0;
color: #0003;
font-size: 1.1em;
text-align: center;
}
.yar-post-content :global(hr::before) {
content: "* * *";
}
.yar-post-content :global(pre) {
overflow-x: auto;
border-radius: 0.8em;
background: #f6f8fa;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 8%);
color: #263238;
font-size: 0.92em;
line-height: 1.7;
margin: 1.4em 0;
padding: 1em 1.2em;
}
.yar-post-content :global(pre code) {
background: transparent;
color: inherit;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: inherit;
line-height: inherit;
padding: 0;
}
.yar-post-content :global(:not(pre) > code) {
border-radius: 0.35em;
background: #f3f6f4;
color: #1d3557;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
padding: 0.15em 0.35em;
}
.yar-post-content :global(a) {
color: #1d3557;
font-weight: 600;
text-decoration: underline;
text-decoration-color: rgb(29 53 87 / 24%);
text-decoration-thickness: 0.12em;
text-underline-offset: 0.2em;
}
.yar-post-content :global(a:hover) {
background: #f3f6f4;
text-decoration-color: rgb(29 53 87 / 48%);
}
.yar-post-content :global(p) {
color: #596254;
line-height: 1.8;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-back-row,
.yar-article,
.yar-footer {
width: calc(100% - 28px);
}
.yar-date {
white-space: normal;
}
}
</style>
<script>
const backLink = document.querySelector('[data-history-back]');
backLink?.addEventListener('click', (event) => {
if (window.history.length <= 1) {
return;
}
event.preventDefault();
window.history.back();
});
</script>

View file

@ -0,0 +1,237 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import type { Post, TagSummary } from '../../../lib/posts';
type Props = {
tag: TagSummary;
posts: Post[];
};
const { tag, posts } = Astro.props;
---
<main class="yar-tag-page">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-tag-posts" aria-label={`Posts tagged ${tag.name}`}>
<div class="yar-current-tag">
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</div>
<div class="yar-post-list">
{posts.map((post) => (
<article class="yar-post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
</a>
</article>
))}
</div>
</section>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-tag-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-tag-posts {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
color: #3d3d3f;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
letter-spacing: 1px;
line-height: 1.7em;
}
.yar-current-tag {
display: inline-flex;
gap: 0.7em;
align-items: center;
margin-bottom: 2em;
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
padding: 0.7em 1em;
}
.yar-current-tag strong {
color: #1d3557;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
}
.yar-post-list {
display: grid;
gap: 0.9em;
}
.yar-post-item a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1em;
align-items: center;
border-radius: 0.75em;
color: inherit;
padding: 0.8em 1em;
text-decoration: none;
}
.yar-post-item a:hover {
background: #ffffff;
box-shadow: 0 0em 0.35em rgb(29 53 87 / 13%);
}
.yar-post-item h2 {
margin: 0;
font-size: 1em;
line-height: 1.4;
}
.yar-post-item time {
color: #7a8375;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
white-space: nowrap;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-tag-posts,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.yar-post-item a {
grid-template-columns: 1fr;
gap: 0.45em;
}
}
</style>

View file

@ -0,0 +1,210 @@
---
import SiteNav from '../../SiteNav.astro';
import { site } from '../../../lib/siteConfig';
import type { TagSummary } from '../../../lib/posts';
type Props = {
tags: TagSummary[];
};
const { tags } = Astro.props;
---
<main class="yar-tags-page">
<div class="yar-shell">
<header class="yar-header">
<div class="yar-header-inner">
<div class="yar-title-box">
<h1 class="yar-title">{site.title}</h1>
</div>
<SiteNav />
</div>
</header>
<section class="yar-tags-section" aria-label="Tags">
{tags.length === 0 ? (
<p class="yar-empty">No tags yet.</p>
) : (
<ul class="yar-tag-index">
{tags.map((tag) => (
<li>
<a href={`/tags/${tag.slug}/`}>
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</a>
</li>
))}
</ul>
)}
</section>
<footer class="yar-footer">
<p>由 osaet 构建</p>
<p>© 2026 Osaet. All rights reserved.</p>
</footer>
</div>
</main>
<style>
.yar-tags-page {
min-height: 100vh;
background: #ffffff;
color: #1c211b;
}
.yar-shell {
display: flex;
min-height: 100vh;
flex-direction: column;
width: 100%;
padding: 40px 0 5vh;
}
.yar-header {
border-bottom: 1px solid rgba(28, 33, 27, 0.18);
}
.yar-header-inner {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
width: 50vw;
margin: 0 auto;
padding-bottom: 28px;
}
.yar-shell :global(.site-nav) {
display: flex;
flex-wrap: wrap;
gap: 0.25em;
justify-content: flex-end;
margin: 0;
border-radius: 0.75em;
background: #ffffff;
box-shadow: 0 0em 0.2em rgb(29 53 87 / 13%);
padding: 0.5em 1.4em;
color: #53605a;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.86rem;
}
.yar-shell :global(.site-nav a) {
border-radius: 0.65em;
color: inherit;
padding: 0.55em 0.8em;
text-decoration: none;
}
.yar-shell :global(.site-nav a:hover) {
background: #eef1ed;
text-decoration: none;
}
.yar-title-box {
border-radius: 0.7em;
background: #1d3557;
padding: 1em 1.4em;
}
.yar-title {
margin: 0;
color: #ffffff;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
font-size: 1em;
letter-spacing: 0.16em;
line-height: 1.15;
}
.yar-tags-section {
width: 50vw;
margin: 0 auto;
padding: 36px 0 0;
color: #3d3d3f;
font-family:
"Heti Hei", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans CJK SC", "Source Han Sans SC", "Source Han Sans CN",
sans-serif;
font-size: 16px;
letter-spacing: 1px;
line-height: 1.7em;
}
.yar-tag-index {
display: flex;
flex-wrap: wrap;
gap: 0.8em;
padding: 0;
margin: 0;
list-style: none;
}
.yar-tag-index a {
display: inline-flex;
gap: 0.7em;
align-items: center;
border-radius: 999em;
background: #f3f6f4;
box-shadow: inset 0 0 0 1px rgb(29 53 87 / 7%);
color: #53605a;
padding: 0.7em 1em;
text-decoration: none;
}
.yar-tag-index a:hover {
background: #e8eee9;
}
.yar-tag-index strong {
color: #1d3557;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
font-weight: 700;
}
.yar-empty {
margin: 0;
color: #596254;
text-align: center;
}
.yar-footer {
width: 50vw;
margin: auto auto 0;
color: #8a9089;
font-family:
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-size: 0.78rem;
line-height: 1.8;
text-align: center;
}
.yar-footer p {
margin: 0;
}
@media (max-width: 760px) {
.yar-shell {
padding-top: 24px;
}
.yar-header-inner,
.yar-tags-section,
.yar-footer {
width: calc(100% - 28px);
}
.yar-header-inner {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
}
</style>

View file

@ -5,15 +5,18 @@ type MarkdownPost = {
summary?: string;
status?: string;
tags?: string[];
created_at?: string;
published_at?: string;
updated_at?: string;
};
Content: any;
};
export type Post = MarkdownPost['frontmatter'] & {
url: string;
date: string;
tags: string[];
Content: any;
};
export type TagSummary = {
@ -27,23 +30,40 @@ export type ArchiveYear = {
posts: Post[];
};
export const POSTS_PER_PAGE = 6;
function isPublicStatus(status?: string) {
return status === 'published' || status === 'archived';
}
export function getPublishedPosts(): Post[] {
const modules = import.meta.glob('../../../../content/posts/*.md', { eager: true });
return Object.values(modules)
.map((post) => {
const frontmatter = (post as MarkdownPost).frontmatter;
const Content = (post as MarkdownPost).Content;
return {
...frontmatter,
Content,
url: `/posts/${frontmatter.slug}/`,
date: frontmatter.published_at ?? frontmatter.updated_at ?? '',
date: frontmatter.created_at ?? frontmatter.published_at ?? frontmatter.updated_at ?? '',
tags: frontmatter.tags ?? []
};
})
.filter((post) => post.status === 'published')
.filter((post) => isPublicStatus(post.status))
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
}
export function getPaginatedPosts(posts: Post[], page: number, perPage = POSTS_PER_PAGE): Post[] {
const start = (page - 1) * perPage;
return posts.slice(start, start + perPage);
}
export function getTotalPages(posts: Post[], perPage = POSTS_PER_PAGE): number {
return Math.max(1, Math.ceil(posts.length / perPage));
}
export function tagSlug(tag: string): string {
return encodeURIComponent(tag.trim().toLowerCase().replace(/\s+/g, '-'));
}

View file

@ -12,6 +12,7 @@ type SiteConfig = {
base_url: string;
language: string;
timezone: string;
theme: string;
};
content: {
posts_dir: string;
@ -29,7 +30,8 @@ const defaults: SiteConfig = {
description: 'Personal blog',
base_url: 'http://localhost:4321',
language: 'zh-CN',
timezone: 'Asia/Shanghai'
timezone: 'Asia/Shanghai',
theme: 'default'
},
content: {
posts_dir: 'content/posts',

View file

@ -1,10 +1,13 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultArchive from '../../components/DefaultArchive.astro';
import YarArchive from '../../components/themes/yar/YarArchive.astro';
import { site } from '../../lib/siteConfig';
import { getArchiveYears, tagSlug } from '../../lib/posts';
import { getArchiveYears } from '../../lib/posts';
const archiveYears = getArchiveYears();
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const ArchiveView = theme === 'yar' ? YarArchive : DefaultArchive;
---
<!doctype html>
@ -16,49 +19,6 @@ const archiveYears = getArchiveYears();
<meta name="description" content={`Archive of ${site.title}`} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>Archive</h1>
</header>
{archiveYears.length === 0 ? (
<p class="empty">No published posts yet.</p>
) : (
<div class="archive-list">
{archiveYears.map((group) => (
<section class="archive-year" aria-label={group.year}>
<h2>{group.year}</h2>
<ol>
{group.posts.map((post) => (
<li>
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language, {
month: '2-digit',
day: '2-digit'
})}
</time>
)}
<a href={post.url}>{post.title}</a>
{post.tags.length > 0 && (
<ul class="tags compact-tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</li>
))}
</ol>
</section>
))}
</div>
)}
</main>
<ArchiveView archiveYears={archiveYears} />
</body>
</html>

View file

@ -1,10 +1,15 @@
---
import '../styles/global.css';
import SiteNav from '../components/SiteNav.astro';
import '../styles/normalize.css';
import DefaultHome from '../components/DefaultHome.astro';
import YarHome from '../components/themes/yar/YarHome.astro';
import { site } from '../lib/siteConfig';
import { getPublishedPosts, tagSlug } from '../lib/posts';
import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../lib/posts';
const posts = getPublishedPosts();
const allPosts = getPublishedPosts();
const posts = getPaginatedPosts(allPosts, 1);
const totalPages = getTotalPages(allPosts);
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const Home = theme === 'yar' ? YarHome : DefaultHome;
---
<!doctype html>
@ -16,44 +21,6 @@ const posts = getPublishedPosts();
<meta name="description" content={site.description} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.description}</p>
<h1>{site.title}</h1>
</header>
<section class="post-list" aria-label="Posts">
{posts.length === 0 ? (
<p class="empty">No published posts yet.</p>
) : (
posts.map((post) => (
<article class="post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.summary && <p>{post.summary}</p>}
<div class="post-meta">
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
{post.tags.length > 0 && (
<ul class="tags" aria-label="Tags">
{post.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</div>
</a>
</article>
))
)}
</section>
</main>
<Home posts={posts} currentPage={1} totalPages={totalPages} />
</body>
</html>

View file

@ -0,0 +1,40 @@
---
import '../../styles/normalize.css';
import DefaultHome from '../../components/DefaultHome.astro';
import YarHome from '../../components/themes/yar/YarHome.astro';
import { site } from '../../lib/siteConfig';
import { getPaginatedPosts, getPublishedPosts, getTotalPages } from '../../lib/posts';
export function getStaticPaths() {
const allPosts = getPublishedPosts();
const totalPages = getTotalPages(allPosts);
return Array.from({ length: Math.max(0, totalPages - 1) }, (_, index) => {
const page = index + 2;
return {
params: { page: String(page) },
props: { page }
};
});
}
const { page } = Astro.props;
const allPosts = getPublishedPosts();
const posts = getPaginatedPosts(allPosts, page);
const totalPages = getTotalPages(allPosts);
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const Home = theme === 'yar' ? YarHome : DefaultHome;
---
<!doctype html>
<html lang={site.language}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>第 {page} 页 | {site.title}</title>
<meta name="description" content={site.description} />
</head>
<body>
<Home posts={posts} currentPage={page} totalPages={totalPages} />
</body>
</html>

View file

@ -1,13 +1,13 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultPost from '../../components/DefaultPost.astro';
import YarPost from '../../components/themes/yar/YarPost.astro';
import { site } from '../../lib/siteConfig';
import { tagSlug } from '../../lib/posts';
export function getStaticPaths() {
const modules = import.meta.glob('../../../../../content/posts/*.md', { eager: true });
return Object.values(modules)
.filter((post) => post.frontmatter.status === 'published')
.filter((post) => post.frontmatter.status === 'published' || post.frontmatter.status === 'archived')
.map((post) => ({
params: { slug: post.frontmatter.slug },
props: { post }
@ -15,8 +15,9 @@ export function getStaticPaths() {
}
const { post } = Astro.props;
const { Content } = post;
const title = post.frontmatter.title;
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const PostView = theme === 'yar' ? YarPost : DefaultPost;
---
<!doctype html>
@ -28,29 +29,6 @@ const title = post.frontmatter.title;
{post.frontmatter.summary && <meta name="description" content={post.frontmatter.summary} />}
</head>
<body>
<main class="page article-page">
<SiteNav />
<article class="article">
<header>
<h1>{title}</h1>
<time datetime={post.frontmatter.published_at ?? post.frontmatter.updated_at}>
{new Date(post.frontmatter.published_at ?? post.frontmatter.updated_at).toLocaleDateString(site.language)}
</time>
{post.frontmatter.summary && <p class="summary">{post.frontmatter.summary}</p>}
{post.frontmatter.tags?.length > 0 && (
<ul class="tags article-tags" aria-label="Tags">
{post.frontmatter.tags.map((tag) => (
<li>
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
</li>
))}
</ul>
)}
</header>
<Content />
</article>
</main>
<PostView post={post} />
</body>
</html>

View file

@ -1,6 +1,7 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultTagPosts from '../../components/DefaultTagPosts.astro';
import YarTagPosts from '../../components/themes/yar/YarTagPosts.astro';
import { site } from '../../lib/siteConfig';
import { getPostsByTag, getTagSummaries } from '../../lib/posts';
@ -13,6 +14,8 @@ export function getStaticPaths() {
const { tag } = Astro.props;
const posts = getPostsByTag(tag.slug);
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const TagPostsView = theme === 'yar' ? YarTagPosts : DefaultTagPosts;
---
<!doctype html>
@ -24,29 +27,6 @@ const posts = getPostsByTag(tag.slug);
<meta name="description" content={`Posts tagged ${tag.name}`} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>{tag.name}</h1>
</header>
<section class="post-list" aria-label={`Posts tagged ${tag.name}`}>
{posts.map((post) => (
<article class="post-item">
<a href={post.url}>
<h2>{post.title}</h2>
{post.summary && <p>{post.summary}</p>}
{post.date && (
<time datetime={post.date}>
{new Date(post.date).toLocaleDateString(site.language)}
</time>
)}
</a>
</article>
))}
</section>
</main>
<TagPostsView tag={tag} posts={posts} />
</body>
</html>

View file

@ -1,10 +1,13 @@
---
import '../../styles/global.css';
import SiteNav from '../../components/SiteNav.astro';
import '../../styles/normalize.css';
import DefaultTags from '../../components/DefaultTags.astro';
import YarTags from '../../components/themes/yar/YarTags.astro';
import { site } from '../../lib/siteConfig';
import { getTagSummaries } from '../../lib/posts';
const tags = getTagSummaries();
const theme = site.theme?.trim().toLowerCase() ?? 'default';
const TagsView = theme === 'yar' ? YarTags : DefaultTags;
---
<!doctype html>
@ -16,28 +19,6 @@ const tags = getTagSummaries();
<meta name="description" content={`Tags on ${site.title}`} />
</head>
<body>
<main class="page">
<SiteNav />
<header class="site-header">
<p class="eyebrow">{site.title}</p>
<h1>Tags</h1>
</header>
{tags.length === 0 ? (
<p class="empty">No tags yet.</p>
) : (
<ul class="tag-index" aria-label="Tags">
{tags.map((tag) => (
<li>
<a href={`/tags/${tag.slug}/`}>
<span>{tag.name}</span>
<strong>{tag.count}</strong>
</a>
</li>
))}
</ul>
)}
</main>
<TagsView tags={tags} />
</body>
</html>

180
frontend/site/src/styles/normalize.css vendored Normal file
View file

@ -0,0 +1,180 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
html {
line-height: 1.15;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
}
main {
display: block;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre {
font-family: monospace, monospace;
font-size: 1em;
}
a {
background-color: transparent;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
[hidden] {
display: none;
}