Add R2 image uploads to admin
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
parent
9186801c7f
commit
49a0d078da
16 changed files with 809 additions and 14 deletions
|
|
@ -4,6 +4,7 @@ import { Injectable, inject } from '@angular/core';
|
|||
import {
|
||||
AuditLogsResponse,
|
||||
BuildJobResponse,
|
||||
ImageUploadResponse,
|
||||
LoginResponse,
|
||||
DeletePostResponse,
|
||||
PostInput,
|
||||
|
|
@ -129,4 +130,12 @@ export class AdminApiService {
|
|||
withCredentials: true
|
||||
});
|
||||
}
|
||||
|
||||
uploadImage(file: File) {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
return this.http.post<ImageUploadResponse>(`${this.baseUrl}/images`, body, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ label {
|
|||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.body-field {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
color: #55575d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
|
|
@ -560,6 +567,43 @@ textarea {
|
|||
gap: 1em;
|
||||
}
|
||||
|
||||
.body-tools {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45em;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.image-upload-button {
|
||||
width: auto;
|
||||
min-height: 2.35em;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 0.65em;
|
||||
background: #fff;
|
||||
color: #2f4a63;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
line-height: 1;
|
||||
padding: 0 0.8em;
|
||||
}
|
||||
|
||||
.image-upload-button:hover {
|
||||
background: #f1f3f5;
|
||||
color: #1c3147;
|
||||
}
|
||||
|
||||
.image-upload-button.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.image-upload-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -604,6 +648,7 @@ textarea {
|
|||
}
|
||||
|
||||
.markdown-preview {
|
||||
container-type: inline-size;
|
||||
overflow: auto;
|
||||
border: 1px solid #e8e5df;
|
||||
border-radius: 0.7em;
|
||||
|
|
@ -636,9 +681,31 @@ textarea {
|
|||
|
||||
.markdown-preview img {
|
||||
display: block;
|
||||
max-width: min(100%, 42em);
|
||||
max-width: 80%;
|
||||
max-width: min(100%, 80cqw);
|
||||
max-height: min(48vh, 72cqw);
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0.7em;
|
||||
margin: 1.2em auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-preview p > img,
|
||||
:host ::ng-deep .markdown-preview img {
|
||||
display: block;
|
||||
max-width: 80%;
|
||||
max-width: min(100%, 80cqw);
|
||||
max-height: min(48vh, 72cqw);
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0.7em;
|
||||
margin: 1.2em auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:host ::ng-deep .markdown-preview p:has(> img:only-child) {
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
|
||||
.markdown-preview pre {
|
||||
|
|
@ -827,4 +894,9 @@ textarea {
|
|||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.body-tools {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -306,19 +306,30 @@
|
|||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="body-field">
|
||||
<div 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 class="body-tools">
|
||||
<label class="image-upload-button" [class.disabled]="uploadingImage">
|
||||
{{ uploadingImage ? '上传中' : '上传图片' }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
[disabled]="uploadingImage"
|
||||
(change)="uploadImage($event)"
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
</span>
|
||||
|
||||
|
|
@ -336,7 +347,7 @@
|
|||
<article class="markdown-preview" [innerHTML]="previewHtml"></article>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="editor-status-row">
|
||||
<p class="message">{{ editorMessage }}</p>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
loading = true;
|
||||
saving = false;
|
||||
generatingSlug = false;
|
||||
uploadingImage = false;
|
||||
autosaveStatus = '未修改';
|
||||
lastAutosavedAt: Date | null = null;
|
||||
|
||||
|
|
@ -485,6 +486,31 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
async uploadImage(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadingImage = true;
|
||||
this.editorMessage = '正在上传图片';
|
||||
try {
|
||||
const response = await firstValueFrom(this.api.uploadImage(file));
|
||||
this.insertMarkdown(response.image.markdown);
|
||||
this.editorMessage = '图片已上传并插入正文';
|
||||
this.showFeedback('图片上传成功', response.image.url);
|
||||
this.updateAutosaveStatus();
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
this.editorMessage = message;
|
||||
this.showFeedback('图片上传失败', message, 'error');
|
||||
} finally {
|
||||
this.uploadingImage = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
|
||||
}
|
||||
|
|
@ -612,6 +638,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||
this.feedbackTimer = null;
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
private insertMarkdown(markdown: string) {
|
||||
const body = this.draft.bodyMarkdown.trimEnd();
|
||||
this.draft.bodyMarkdown = body ? `${body}\n\n${markdown}\n` : `${markdown}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[]) {
|
||||
|
|
|
|||
|
|
@ -98,3 +98,16 @@ export type AuditLogsResponse = {
|
|||
logs: AuditLog[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type UploadedImage = {
|
||||
key: string;
|
||||
url: string;
|
||||
markdown: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export type ImageUploadResponse = {
|
||||
image: UploadedImage;
|
||||
};
|
||||
|
|
|
|||
18
frontend/site/public/favicon.svg
Normal file
18
frontend/site/public/favicon.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg width="400" height="400" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="faviconGradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#e5485f"/>
|
||||
<stop offset="100%" stop-color="#25aba4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<text
|
||||
x="200"
|
||||
y="250"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
|
||||
font-size="270"
|
||||
font-weight="800"
|
||||
letter-spacing="-4"
|
||||
fill="url(#faviconGradient)">´༥`</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 612 B |
|
|
@ -36,6 +36,7 @@ const rssUrl = absoluteUrl('/rss.xml');
|
|||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
<link rel="alternate" type="application/rss+xml" title={`${site.title} RSS`} href={rssUrl} />
|
||||
<meta property="og:site_name" content={site.title} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue