wow
This commit is contained in:
parent
b12ebb725d
commit
5207f5d431
25 changed files with 2932 additions and 332 deletions
|
|
@ -1,6 +1,7 @@
|
|||
- [ ] Login
|
||||
- [x] Login
|
||||
- [ ] Upload UI
|
||||
- [ ] Hover시에 아래에 약간 책갈피처럼 작가 정보 나오게 하기
|
||||
- [ ] Pixiv Import
|
||||
- [x] Hover시에 아래에 약간 책갈피처럼 작가 정보 나오게 하기
|
||||
- [x] Pixiv Import
|
||||
- [ ] Tag Search
|
||||
- [ ] Edit (우클릭하면 나오게 하면 될듯)
|
||||
- [ ] Tag를 좀 더 이쁜 방식으로 보여주면 좋을듯 + 작가 필터
|
||||
|
|
@ -7,9 +7,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1030.0",
|
||||
"@elysiajs/jwt": "^1.4.1",
|
||||
"@elysiajs/openapi": "^1.4.14",
|
||||
"elysia": "latest",
|
||||
"mongoose": "^9.4.1"
|
||||
"mongoose": "^9.4.1",
|
||||
"ulid": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
|
|
|
|||
|
|
@ -2,202 +2,17 @@ import { Elysia, t } from "elysia";
|
|||
import config from "../config.toml";
|
||||
import * as mongoose from "mongoose";
|
||||
import openapi from "@elysiajs/openapi";
|
||||
import { MediaUpload } from "@/models/media";
|
||||
import { Tag } from "@/models/tag";
|
||||
import { checkTweetData, fetchTweetData } from "./lib/tweet";
|
||||
import { makeS3FileName, s3Client, uploadToS3 } from "./lib/s3";
|
||||
import { normalizeQueryTags, normalizeTags } from "./lib/tag";
|
||||
|
||||
await mongoose.connect(config.mongodb.uri);
|
||||
|
||||
const inFlightUploads = new Set<string>();
|
||||
|
||||
function buildUploadKey(url: string, selected: boolean[]) {
|
||||
const match = url.match(/\/status\/(\d+)/);
|
||||
const tweetId = match?.[1] ?? url;
|
||||
const selectedIndices = selected
|
||||
.map((isSelected, index) => (isSelected ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
.join(",");
|
||||
|
||||
return `${tweetId}:${selectedIndices}`;
|
||||
}
|
||||
|
||||
async function saveTags(tags: string[]) {
|
||||
await Promise.all(
|
||||
tags.map((tag) =>
|
||||
Tag.updateOne(
|
||||
{ name: tag },
|
||||
{
|
||||
$inc: { usageCount: 1 },
|
||||
$set: { lastUsedAt: new Date() },
|
||||
$setOnInsert: { name: tag },
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const app = new Elysia()
|
||||
.use(openapi())
|
||||
.get("/", () => "어...")
|
||||
|
||||
.get("/total", async ({ query }) => {
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const count = await MediaUpload.countDocuments(filter);
|
||||
return count;
|
||||
}, {
|
||||
query: t.Object({
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/list", async ({ query }) => {
|
||||
const page = query.page;
|
||||
const pageSize = 20;
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const uploads = await MediaUpload.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize);
|
||||
return uploads;
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Number({default: 1, minimum: 1}),
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/tags", async () => {
|
||||
const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 });
|
||||
return tags;
|
||||
})
|
||||
|
||||
.post("/upload", async ({ body, status }) => {
|
||||
if (body.url.startsWith("https://www.pixiv.net/")) {
|
||||
return "저는 저능아입니다";
|
||||
} else if (body.url.startsWith("https://x.com/") || body.url.startsWith("https://twitter.com/") || body.url.startsWith("https://fxtwitter.com/") || body.url.startsWith("https://fixupx.com/") || body.url.startsWith("https://vxwitter.com/")) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, "이미 처리 중인 업로드입니다.");
|
||||
}
|
||||
|
||||
inFlightUploads.add(uploadKey);
|
||||
console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`);
|
||||
|
||||
if (await checkTweetData(body.url, body.selected)) {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
console.log(`[Upload skipped-existing] requestId=${requestId} key=${uploadKey}`);
|
||||
return "이미 저장된 트윗입니다.";
|
||||
}
|
||||
try {
|
||||
const tweetData = await fetchTweetData(body.url);
|
||||
if (tweetData.tweet) {
|
||||
const media = tweetData.tweet.media.photos || [];
|
||||
if (media.length > 0) {
|
||||
const mediaUrls = media.map((m: any) => m.url);
|
||||
// Upload to S3
|
||||
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
const hasExplicitSelection = body.selected.length > 0;
|
||||
for (const [index, url] of mediaUrls.entries()) {
|
||||
const isSelected = hasExplicitSelection
|
||||
? body.selected[index] === true
|
||||
: true;
|
||||
|
||||
if (!isSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, url, index);
|
||||
|
||||
try {
|
||||
if (await s3Client.exists(fileName)) {
|
||||
console.log(`File ${fileName} already exists in S3, skipping upload.`);
|
||||
} else {
|
||||
await uploadToS3(fileName, url);
|
||||
console.log(`Uploaded ${fileName} to S3`);
|
||||
}
|
||||
|
||||
const { media: _media, ...tweetWithoutMedia } = tweetData.tweet;
|
||||
const normalizedTags = normalizeTags(body.tag || ["미분류"]);
|
||||
|
||||
await MediaUpload.create({
|
||||
type: "twitter",
|
||||
tweet: tweetWithoutMedia,
|
||||
mediaIndex: index,
|
||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||
s3Key: fileName,
|
||||
tags: normalizedTags,
|
||||
author: body.author ? body.author : tweetData.tweet.author.name,
|
||||
});
|
||||
|
||||
await saveTags(normalizedTags);
|
||||
savedCount += 1;
|
||||
} catch (error) {
|
||||
failedCount += 1;
|
||||
console.error(`[Upload failed] index=${index} url=${url} key=${fileName}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === 0 && failedCount === 0) {
|
||||
console.warn("No media uploaded: selected[] did not include any upload target.");
|
||||
}
|
||||
|
||||
console.log(`Saved ${savedCount} media records to MongoDB. Failed: ${failedCount}`);
|
||||
} else {
|
||||
console.log("No media found in the tweet.");
|
||||
}
|
||||
}
|
||||
console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`);
|
||||
console.log(tweetData);
|
||||
} catch (error) {
|
||||
console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error);
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else {
|
||||
return status(400, "어...");
|
||||
}
|
||||
return "아...";
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String(),
|
||||
tag: t.Optional(t.Array(t.String({default: "미분류"}))),
|
||||
author: t.Optional(t.String()),
|
||||
selected: t.Array(t.Boolean()),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/fetch/tweet", async ({ query, status }) => {
|
||||
try {
|
||||
const tweetData = await fetchTweetData(query.url);
|
||||
return tweetData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
url: t.String(),
|
||||
})
|
||||
})
|
||||
.use(import("./routes/tweet"))
|
||||
.use(import("./routes/auth"))
|
||||
.use(import("./routes/post"))
|
||||
.use(import("./routes/pixiv"))
|
||||
|
||||
.listen(config.server.port)
|
||||
;
|
||||
|
|
|
|||
34
apps/backend/src/lib/audit.ts
Normal file
34
apps/backend/src/lib/audit.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { AuditLog } from "@/models/audit";
|
||||
|
||||
type AuditActor = {
|
||||
userId?: string;
|
||||
discordId?: string;
|
||||
username?: string;
|
||||
role?: string;
|
||||
};
|
||||
|
||||
type AuditInput = {
|
||||
actor?: AuditActor;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
summary?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function createAuditLog(input: AuditInput) {
|
||||
try {
|
||||
await AuditLog.create({
|
||||
actor: input.actor,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
summary: input.summary,
|
||||
detail: input.detail,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AuditLog] failed to save", error);
|
||||
}
|
||||
}
|
||||
|
||||
export { createAuditLog };
|
||||
33
apps/backend/src/lib/pixiv.ts
Normal file
33
apps/backend/src/lib/pixiv.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
function fetchPixivData(url: string): Promise<any> {
|
||||
// https://www.pixiv.net/artworks/143552616
|
||||
const match = url.match(/\/artworks\/(\d+)/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid Pixiv URL");
|
||||
}
|
||||
const artworkId = match[1];
|
||||
|
||||
|
||||
return fetch(`https://www.phixiv.net/api/info?id=${artworkId}&language=ko`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Pixiv data: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
// if #R-18 in tags, throw error
|
||||
if (data.tags && data.tags.some((tag: string) => tag.includes("R-18"))) {
|
||||
throw new Error("Pixiv artwork is marked as R-18");
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
throw new Error(`Pixiv API error: ${data.message}`);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export { fetchPixivData };
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { S3Client } from "bun";
|
||||
import {
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client as AwsS3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import config from "@/../config.toml";
|
||||
|
||||
// Bun.S3Client 단일화
|
||||
const client = new S3Client({
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_key,
|
||||
|
|
@ -13,108 +9,58 @@ const client = new S3Client({
|
|||
endpoint: config.s3.endpoint,
|
||||
});
|
||||
|
||||
const awsClient = new AwsS3Client({
|
||||
region: "auto",
|
||||
endpoint: config.s3.endpoint,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: config.s3.access_key,
|
||||
secretAccessKey: config.s3.secret_key,
|
||||
},
|
||||
});
|
||||
async function warmupS3() {
|
||||
try {
|
||||
await client.exists(`__warmup_${Date.now()}__`);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeS3FileName(authorId: string, tweetId: string, mediaUrl: string, index: number) {
|
||||
const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`;
|
||||
const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`;
|
||||
const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `twitter/${authorId}/${tweetId}/${safeName || `media_${Date.now()}_${index}`}`;
|
||||
return `twitter/${authorId}/${tweetId}/${safeName}`;
|
||||
}
|
||||
|
||||
async function uploadToS3(fileName: string, mediaUrl: string, maxRetry = 3) {
|
||||
async function existsInS3(key: string) {
|
||||
try {
|
||||
return await client.exists(key);
|
||||
} catch {
|
||||
try {
|
||||
await awsClient.send(new HeadObjectCommand({
|
||||
Bucket: config.s3.bucket,
|
||||
Key: key,
|
||||
}));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToS3(key: string, body: Uint8Array, mediaType?: string | null) {
|
||||
try {
|
||||
await client.write(key, body);
|
||||
return;
|
||||
} catch (bunWriteError) {
|
||||
console.warn(`[S3 bun write failed, fallback to aws-sdk] key=${key}`, bunWriteError);
|
||||
await awsClient.send(new PutObjectCommand({
|
||||
Bucket: config.s3.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: mediaType ?? undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverByPollingExists(reason: string) {
|
||||
for (let probe = 1; probe <= 4; probe++) {
|
||||
await Bun.sleep(probe * 600);
|
||||
try {
|
||||
if (await existsInS3(fileName)) {
|
||||
console.warn(`[S3 upload recovered-${reason}] key=${fileName} probe=${probe}`);
|
||||
return true;
|
||||
}
|
||||
} catch (existsError) {
|
||||
console.error(`[S3 exists probe failed] key=${fileName} probe=${probe}`, existsError);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
// 1. 요청 시작 전 예열 수행
|
||||
await warmupS3();
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
||||
try {
|
||||
const response = await fetch(mediaUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch media from ${mediaUrl}: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Fetch failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await writeToS3(fileName, buffer, response.headers.get("content-type"));
|
||||
return;
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
// 2. Bun S3 Client만 사용하여 쓰기
|
||||
// client.write는 내부적으로 효율적인 스트리밍/버퍼 처리를 수행합니다.
|
||||
await client.write(fileName, buffer, {
|
||||
type: response.headers.get("content-type") || "application/octet-stream",
|
||||
});
|
||||
|
||||
return; // 성공 시 리턴
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName} url=${mediaUrl}`, error);
|
||||
|
||||
const errorCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: "";
|
||||
|
||||
// Some S3 providers return UnknownError even when the object is eventually persisted.
|
||||
if (errorCode === "UnknownError") {
|
||||
if (await recoverByPollingExists("unknown")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.error(`[S3 upload attempt ${attempt}/${maxRetry}] key=${fileName}`, error);
|
||||
|
||||
// UnknownError 발생 시 잠시 대기 후 재시도 (지수 백오프)
|
||||
if (attempt < maxRetry) {
|
||||
await Bun.sleep(attempt * 800);
|
||||
await Bun.sleep(attempt * 1000);
|
||||
// 재시도 전 다시 한번 예열 시도 가능
|
||||
await warmupS3();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final guard: do one last exists check before surfacing failure.
|
||||
if (await recoverByPollingExists("final")) {
|
||||
// 최종 실패 전 마지막 확인 (이미 올라갔을 수도 있음)
|
||||
if (await client.exists(fileName)) {
|
||||
console.warn(`[S3 upload recovered] key=${fileName} was found after error.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ async function checkTweetData(url: string, selected: Array<boolean>) {
|
|||
}
|
||||
const tweetId = match[1];
|
||||
// find in mongodb if there is already a record with the same tweet id and media index in selected
|
||||
const existing = await MediaUpload.findOne({ "tweetData.tweet.id": tweetId, mediaIndex: { $in: selected.map((s, i) => s ? i : -1).filter(i => i >= 0) } });
|
||||
const existing = await MediaUpload.findOne({ "tweet.id": tweetId, mediaIndex: { $in: selected.map((s, i) => s ? i : -1).filter(i => i >= 0) } });
|
||||
if (existing) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function fetchTweetData(url: string) {
|
||||
|
|
|
|||
19
apps/backend/src/models/audit.ts
Normal file
19
apps/backend/src/models/audit.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as mongoose from "mongoose";
|
||||
|
||||
const auditLogSchema = new mongoose.Schema({
|
||||
actor: {
|
||||
userId: { type: String },
|
||||
discordId: { type: String },
|
||||
username: { type: String },
|
||||
role: { type: String },
|
||||
},
|
||||
action: { type: String, required: true },
|
||||
targetType: { type: String, required: true },
|
||||
targetId: { type: String },
|
||||
summary: { type: String },
|
||||
detail: { type: Object },
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export const AuditLog = mongoose.model("AuditLog", auditLogSchema);
|
||||
|
|
@ -13,6 +13,12 @@ const mediaUploadSchema = new mongoose.Schema({
|
|||
s3Key: { type: String, required: true },
|
||||
tags: { type: [String], default: [] },
|
||||
author: { type: String, required: true },
|
||||
uploadedBy: {
|
||||
userId: { type: String },
|
||||
discordId: { type: String },
|
||||
username: { type: String },
|
||||
role: { type: String, enum: ["admin", "writer", "reader"] },
|
||||
},
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
|
|
|||
17
apps/backend/src/models/user.ts
Normal file
17
apps/backend/src/models/user.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as mongoose from "mongoose";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
export const USER_ROLES = ["admin", "writer", "reader"] as const;
|
||||
export type UserRole = typeof USER_ROLES[number];
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
userId: { type: String, required: true, unique: true, default: () => ulid() },
|
||||
discordId: { type: String, required: true, unique: true },
|
||||
username: { type: String, required: true },
|
||||
avatar: { type: String },
|
||||
role: { type: String, enum: USER_ROLES, default: "reader" },
|
||||
}, {
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export const User = mongoose.model("User", userSchema);
|
||||
381
apps/backend/src/routes/auth.ts
Normal file
381
apps/backend/src/routes/auth.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import config from "@/../config.toml";
|
||||
import { jwt } from '@elysiajs/jwt';
|
||||
import { AuditLog } from "@/models/audit";
|
||||
import { USER_ROLES, User } from "@/models/user";
|
||||
import { createAuditLog } from "@/lib/audit";
|
||||
import { ulid } from "ulid";
|
||||
|
||||
const hardcodedOwners = new Set<string>(
|
||||
Array.isArray(config.auth.hardcoded_owner)
|
||||
? (config.auth.hardcoded_owner as Array<string | number>).map((id: string | number) => String(id))
|
||||
: [],
|
||||
);
|
||||
|
||||
export default new Elysia({ prefix: "/auth" })
|
||||
.use(
|
||||
jwt({
|
||||
name: 'jwt',
|
||||
secret: config.auth.jwt_secret,
|
||||
})
|
||||
)
|
||||
.get("/me", async ({ jwt, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const user = await User.findOne({ userId: payload.id });
|
||||
if (!user) {
|
||||
return status(404, "User not found");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.userId,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
};
|
||||
})
|
||||
|
||||
.get("/discord/login", ({ redirect }) => redirect(`https://discord.com/oauth2/authorize?client_id=${config.auth.discord_client_id}&response_type=code&redirect_uri=${encodeURIComponent(config.auth.discord_redirect_uri)}&scope=guilds+identify`))
|
||||
|
||||
.get("/roles", () => USER_ROLES)
|
||||
|
||||
.post("/logout", ({ cookie: { mizuki } }) => {
|
||||
mizuki.set({
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
path: "/api",
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
})
|
||||
|
||||
.get("/discord/callback", async ({ jwt, query, status, cookie: { mizuki }, redirect }) => {
|
||||
const code = query.code;
|
||||
if (!code) {
|
||||
return status(400, "Missing code");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.auth.discord_client_id,
|
||||
client_secret: config.auth.discord_client_secret,
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: config.auth.discord_redirect_uri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
console.error("Failed to exchange code for token", await tokenResponse.text());
|
||||
return status(500, "Failed to exchange code for token");
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
|
||||
const userResponse = await fetch("https://discord.com/api/users/@me", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
console.error("Failed to fetch user info", await userResponse.text());
|
||||
return status(500, "Failed to fetch user info");
|
||||
}
|
||||
|
||||
const userData = await userResponse.json();
|
||||
const isHardcodedOwner = hardcodedOwners.has(String(userData.id));
|
||||
const updateSet: { username: string; avatar: string; role?: "admin" } = {
|
||||
username: userData.username,
|
||||
avatar: userData.avatar,
|
||||
};
|
||||
|
||||
if (isHardcodedOwner) {
|
||||
updateSet.role = "admin";
|
||||
}
|
||||
|
||||
const user = await User.findOneAndUpdate(
|
||||
{ discordId: userData.id },
|
||||
{
|
||||
$set: updateSet,
|
||||
$setOnInsert: {
|
||||
userId: ulid(),
|
||||
discordId: userData.id,
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true },
|
||||
);
|
||||
|
||||
const ensuredUser = user && !user.userId
|
||||
? await User.findOneAndUpdate(
|
||||
{ discordId: userData.id },
|
||||
{ $set: { userId: ulid() } },
|
||||
{ new: true },
|
||||
)
|
||||
: user;
|
||||
|
||||
const token = await jwt.sign({
|
||||
id: ensuredUser?.userId,
|
||||
discordId: userData.id,
|
||||
username: userData.username,
|
||||
avatar: userData.avatar,
|
||||
role: ensuredUser?.role ?? "reader",
|
||||
});
|
||||
|
||||
mizuki.set({
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: "/api",
|
||||
});
|
||||
|
||||
return redirect("/");
|
||||
}, {
|
||||
query: t.Object({
|
||||
code: t.String(),
|
||||
})
|
||||
})
|
||||
|
||||
.post("/role", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const target = await User.findOne({ userId: body.userId });
|
||||
if (!target) {
|
||||
return status(404, "User not found");
|
||||
}
|
||||
|
||||
if (hardcodedOwners.has(target.discordId) && body.role !== "admin") {
|
||||
return status(400, "Hardcoded owner role must remain admin");
|
||||
}
|
||||
|
||||
const updated = await User.findOneAndUpdate(
|
||||
{ userId: body.userId },
|
||||
{ $set: { role: body.role } },
|
||||
{ new: true },
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
return status(404, "User not found");
|
||||
}
|
||||
|
||||
if (requester.userId === updated.userId) {
|
||||
const nextToken = await jwt.sign({
|
||||
id: updated.userId,
|
||||
discordId: updated.discordId,
|
||||
username: updated.username,
|
||||
avatar: updated.avatar,
|
||||
role: updated.role,
|
||||
});
|
||||
|
||||
mizuki.set({
|
||||
value: nextToken,
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: "/api",
|
||||
});
|
||||
}
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "auth.role.update",
|
||||
targetType: "user",
|
||||
targetId: updated.userId,
|
||||
summary: `${requester.username} changed role of ${updated.username} to ${updated.role}`,
|
||||
detail: {
|
||||
role: updated.role,
|
||||
username: updated.username,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.userId,
|
||||
discordId: updated.discordId,
|
||||
username: updated.username,
|
||||
avatar: updated.avatar,
|
||||
role: updated.role,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
userId: t.String(),
|
||||
role: t.Union([
|
||||
t.Literal("admin"),
|
||||
t.Literal("writer"),
|
||||
t.Literal("reader"),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/users", async ({ jwt, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const users = await User.find().sort({ createdAt: -1 });
|
||||
return users.map((user) => ({
|
||||
id: user.userId,
|
||||
discordId: user.discordId,
|
||||
username: user.username,
|
||||
avatar: user.avatar,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
}));
|
||||
})
|
||||
|
||||
.post("/user", async ({ jwt, body, cookie: { mizuki }, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const role = hardcodedOwners.has(body.discordId) ? "admin" : body.role;
|
||||
|
||||
try {
|
||||
const created = await User.create({
|
||||
userId: ulid(),
|
||||
discordId: body.discordId,
|
||||
username: body.username,
|
||||
avatar: body.avatar,
|
||||
role,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "auth.user.create",
|
||||
targetType: "user",
|
||||
targetId: created.userId,
|
||||
summary: `${requester.username} created user ${created.username}`,
|
||||
detail: {
|
||||
username: created.username,
|
||||
discordId: created.discordId,
|
||||
role: created.role,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.userId,
|
||||
discordId: created.discordId,
|
||||
username: created.username,
|
||||
avatar: created.avatar,
|
||||
role: created.role,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create user";
|
||||
return status(400, message);
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
discordId: t.String({ minLength: 1 }),
|
||||
username: t.String({ minLength: 1 }),
|
||||
avatar: t.Optional(t.String()),
|
||||
role: t.Union([
|
||||
t.Literal("admin"),
|
||||
t.Literal("writer"),
|
||||
t.Literal("reader"),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
|
||||
.get("/audit-logs", async ({ jwt, cookie: { mizuki }, query, status }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const page = query.page;
|
||||
const size = query.size;
|
||||
const logs = await AuditLog.find()
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * size)
|
||||
.limit(size);
|
||||
return logs;
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Number({ default: 1, minimum: 1 }),
|
||||
size: t.Number({ default: 30, minimum: 1, maximum: 200 }),
|
||||
}),
|
||||
})
|
||||
17
apps/backend/src/routes/pixiv.ts
Normal file
17
apps/backend/src/routes/pixiv.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { fetchPixivData } from "@/lib/pixiv";
|
||||
import { Elysia, t } from "elysia";
|
||||
|
||||
export default new Elysia({ prefix: "/pixiv" })
|
||||
.get("/fetch", async ({ query, status }) => {
|
||||
try {
|
||||
// Implement Pixiv data fetching logic here
|
||||
return await fetchPixivData(query.url);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch Pixiv data");
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
url: t.String(),
|
||||
})
|
||||
})
|
||||
633
apps/backend/src/routes/post.ts
Normal file
633
apps/backend/src/routes/post.ts
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
import { Elysia, t } from "elysia";
|
||||
import config from "@/../config.toml";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { MediaUpload } from "@/models/media";
|
||||
import { Tag } from "@/models/tag";
|
||||
import { User } from "@/models/user";
|
||||
import { createAuditLog } from "@/lib/audit";
|
||||
import { normalizeQueryTags, normalizeTags } from "@/lib/tag";
|
||||
import { checkTweetData, fetchTweetData } from "@/lib/tweet";
|
||||
import { fetchPixivData } from "@/lib/pixiv";
|
||||
import { makeS3FileName, s3Client, uploadToS3 } from "@/lib/s3";
|
||||
|
||||
const inFlightUploads = new Set<string>();
|
||||
|
||||
function buildUploadKey(url: string, selected: boolean[]) {
|
||||
const match = url.match(/\/status\/(\d+)/);
|
||||
const tweetId = match?.[1] ?? url;
|
||||
const selectedIndices = selected
|
||||
.map((isSelected, index) => (isSelected ? index : -1))
|
||||
.filter((index) => index >= 0)
|
||||
.join(",");
|
||||
|
||||
return `${tweetId}:${selectedIndices}`;
|
||||
}
|
||||
|
||||
async function saveTags(tags: string[]) {
|
||||
await Promise.all(
|
||||
tags.map((tag) =>
|
||||
Tag.updateOne(
|
||||
{ name: tag },
|
||||
{
|
||||
$inc: { usageCount: 1 },
|
||||
$set: { lastUsedAt: new Date() },
|
||||
$setOnInsert: { name: tag },
|
||||
},
|
||||
{ upsert: true },
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function syncTagUsage(previousTags: string[], nextTags: string[]) {
|
||||
const previous = new Set(previousTags);
|
||||
const next = new Set(nextTags);
|
||||
|
||||
const removed = Array.from(previous).filter((tag) => !next.has(tag));
|
||||
const added = Array.from(next).filter((tag) => !previous.has(tag));
|
||||
|
||||
if (added.length > 0) {
|
||||
await saveTags(added);
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
await Promise.all(
|
||||
removed.map((tag) =>
|
||||
Tag.updateOne(
|
||||
{ name: tag },
|
||||
{
|
||||
$inc: { usageCount: -1 },
|
||||
$set: { lastUsedAt: new Date() },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function makePixivFileName(authorId: string, illustId: string, mediaUrl: string, index: number) {
|
||||
const rawName = mediaUrl.split("/").pop() || `media_${Date.now()}_${index}`;
|
||||
const withoutQuery = rawName.split("?")[0]?.split("#")[0] || `media_${Date.now()}_${index}`;
|
||||
const safeName = withoutQuery.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `pixiv/${authorId}/${illustId}/${safeName || `media_${Date.now()}_${index}`}`;
|
||||
}
|
||||
|
||||
async function uploadAndCreateWithRetry(options: {
|
||||
fileName: string;
|
||||
mediaUrl: string;
|
||||
mediaIndex: number;
|
||||
createDocument: () => Promise<void>;
|
||||
}) {
|
||||
const { fileName, mediaUrl, mediaIndex, createDocument } = options;
|
||||
|
||||
const existingBefore = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
|
||||
if (existingBefore) {
|
||||
return { ok: true as const, created: false };
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
if (!(await s3Client.exists(fileName))) {
|
||||
await uploadToS3(fileName, mediaUrl);
|
||||
}
|
||||
|
||||
const existing = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
|
||||
if (existing) {
|
||||
return { ok: true as const, created: false };
|
||||
}
|
||||
|
||||
await createDocument();
|
||||
return { ok: true as const, created: true };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const existingAfterError = await MediaUpload.findOne({ s3Key: fileName, mediaIndex });
|
||||
if (existingAfterError) {
|
||||
return { ok: true as const, created: false };
|
||||
}
|
||||
|
||||
if (attempt < 2) {
|
||||
console.warn(`[Upload retry] key=${fileName} mediaIndex=${mediaIndex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false as const, error: lastError };
|
||||
}
|
||||
|
||||
export default new Elysia({ prefix: "/post" })
|
||||
.use(
|
||||
jwt({
|
||||
name: "jwt",
|
||||
secret: config.auth.jwt_secret,
|
||||
}),
|
||||
)
|
||||
.get("/total", async ({ query }) => {
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const count = await MediaUpload.countDocuments(filter);
|
||||
return count;
|
||||
}, {
|
||||
query: t.Object({
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/list", async ({ query }) => {
|
||||
const page = query.page;
|
||||
const pageSize = query.size || 10;
|
||||
const filterTags = normalizeQueryTags(query.tags);
|
||||
|
||||
const filter = filterTags.length > 0
|
||||
? { tags: { $in: filterTags } }
|
||||
: {};
|
||||
|
||||
const uploads = await MediaUpload.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.skip((page - 1) * pageSize)
|
||||
.limit(pageSize);
|
||||
return uploads;
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Number({ default: 1, minimum: 1 }),
|
||||
size: t.Optional(t.Number({ default: 20, minimum: 1, maximum: 100 })),
|
||||
tags: t.Optional(t.Union([t.String(), t.Array(t.String())])),
|
||||
})
|
||||
})
|
||||
|
||||
.get("/tags", async () => {
|
||||
const tags = await Tag.find().sort({ usageCount: -1, lastUsedAt: -1 });
|
||||
return tags;
|
||||
})
|
||||
|
||||
.get("/detail/:id", async ({ params, status }) => {
|
||||
const post = await MediaUpload.findById(params.id);
|
||||
if (!post) {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
_id: post._id,
|
||||
type: post.type,
|
||||
url: post.tweet?.url,
|
||||
author: post.author,
|
||||
tags: Array.isArray(post.tags) ? post.tags : [],
|
||||
mediaUrl: post.mediaUrl,
|
||||
mediaIndex: post.mediaIndex,
|
||||
};
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
})
|
||||
})
|
||||
|
||||
.delete("/delete/:id", async ({ params, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const targetPost = await MediaUpload.findById(params.id);
|
||||
const result = await MediaUpload.deleteOne({ _id: params.id });
|
||||
if (result.deletedCount === 1) {
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.delete",
|
||||
targetType: "post",
|
||||
targetId: params.id,
|
||||
summary: `${requester.username} deleted a post`,
|
||||
detail: {
|
||||
id: params.id,
|
||||
mediaUrl: targetPost?.mediaUrl,
|
||||
},
|
||||
});
|
||||
return "삭제되었습니다.";
|
||||
} else {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
})
|
||||
})
|
||||
|
||||
.patch("/edit/:id", async ({ params, body, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
const post = await MediaUpload.findById(params.id);
|
||||
if (!post) {
|
||||
return status(404, "포스트를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const nextTags = normalizeTags(body.tag ?? post.tags ?? ["미분류"]);
|
||||
const previousTags = Array.isArray(post.tags) ? post.tags : [];
|
||||
post.tags = nextTags;
|
||||
if (body.author !== undefined) {
|
||||
post.author = body.author.trim() || post.author;
|
||||
}
|
||||
|
||||
await post.save();
|
||||
await syncTagUsage(previousTags, nextTags);
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.edit",
|
||||
targetType: "post",
|
||||
targetId: params.id,
|
||||
summary: `${requester.username} edited a post`,
|
||||
detail: {
|
||||
id: params.id,
|
||||
author: post.author,
|
||||
tags: nextTags,
|
||||
},
|
||||
});
|
||||
|
||||
return "수정되었습니다.";
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String(),
|
||||
}),
|
||||
body: t.Object({
|
||||
tag: t.Optional(t.Array(t.String())),
|
||||
author: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
|
||||
.post("/upload", async ({ body, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
if (body.url.startsWith("https://www.pixiv.net/")) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, "이미 처리 중인 업로드입니다.");
|
||||
}
|
||||
|
||||
inFlightUploads.add(uploadKey);
|
||||
console.log(`[Pixiv upload started] requestId=${requestId} key=${uploadKey}`);
|
||||
|
||||
try {
|
||||
const pixivData = await fetchPixivData(body.url);
|
||||
const mediaUrls: string[] = Array.isArray(pixivData.image_proxy_urls)
|
||||
? pixivData.image_proxy_urls.filter((url: unknown): url is string => typeof url === "string")
|
||||
: [];
|
||||
|
||||
if (mediaUrls.length === 0) {
|
||||
return status(400, "No media found in the Pixiv artwork.");
|
||||
}
|
||||
|
||||
// const normalizedTags = normalizeTags(
|
||||
// body.tag
|
||||
// ?? (Array.isArray(pixivData.tags)
|
||||
// ? pixivData.tags
|
||||
// .filter((tag: unknown): tag is string => typeof tag === "string")
|
||||
// .map((tag: string) => tag.replace(/^#/, ""))
|
||||
// : ["미분류"]),
|
||||
// );
|
||||
|
||||
const normalizedTags = normalizeTags(body.tag ?? ["미분류"]);
|
||||
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
const hasExplicitSelection = body.selected.length > 0;
|
||||
|
||||
for (const [index, mediaUrl] of mediaUrls.entries()) {
|
||||
const isSelected = hasExplicitSelection
|
||||
? body.selected[index] === true
|
||||
: true;
|
||||
|
||||
if (!isSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authorId = String(pixivData.author_id || "unknown");
|
||||
const illustId = String(pixivData.illust_id || `pixiv_${Date.now()}`);
|
||||
const fileName = makePixivFileName(authorId, illustId, mediaUrl, index);
|
||||
|
||||
const result = await uploadAndCreateWithRetry({
|
||||
fileName,
|
||||
mediaUrl,
|
||||
mediaIndex: index,
|
||||
createDocument: async () => {
|
||||
await MediaUpload.create({
|
||||
type: "pixiv",
|
||||
tweet: {
|
||||
id: illustId,
|
||||
url: pixivData.url,
|
||||
title: pixivData.title,
|
||||
authorId,
|
||||
authorName: pixivData.author_name,
|
||||
createDate: pixivData.create_date,
|
||||
},
|
||||
mediaIndex: index,
|
||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||
s3Key: fileName,
|
||||
tags: normalizedTags,
|
||||
author: body.author ? body.author : (pixivData.author_name || "unknown"),
|
||||
uploadedBy: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
failedCount += 1;
|
||||
console.error(`[Pixiv upload failed] index=${index} url=${mediaUrl} key=${fileName}`, result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.created) {
|
||||
await saveTags(normalizedTags);
|
||||
savedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === 0 && failedCount === 0) {
|
||||
console.warn("No Pixiv media uploaded: selected[] did not include any upload target.");
|
||||
}
|
||||
|
||||
console.log(`Saved ${savedCount} Pixiv media records to MongoDB. Failed: ${failedCount}`);
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.upload.pixiv",
|
||||
targetType: "post",
|
||||
summary: `${requester.username} uploaded Pixiv media`,
|
||||
detail: {
|
||||
url: body.url,
|
||||
savedCount,
|
||||
failedCount,
|
||||
},
|
||||
});
|
||||
console.log(`[Pixiv upload finished] requestId=${requestId} key=${uploadKey}`);
|
||||
if (savedCount === 0 && failedCount > 0) {
|
||||
return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
if (savedCount === 0) {
|
||||
return status(400, "선택된 이미지가 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
savedCount,
|
||||
failedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Pixiv upload aborted] requestId=${requestId} key=${uploadKey}`, error);
|
||||
return status(500, "Failed to fetch Pixiv data");
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else if (body.url.startsWith("https://x.com/") || body.url.startsWith("https://twitter.com/") || body.url.startsWith("https://fxtwitter.com/") || body.url.startsWith("https://fixupx.com/") || body.url.startsWith("https://vxwitter.com/")) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const uploadKey = buildUploadKey(body.url, body.selected);
|
||||
|
||||
if (inFlightUploads.has(uploadKey)) {
|
||||
console.warn(`[Upload skipped-duplicate] requestId=${requestId} key=${uploadKey}`);
|
||||
return status(202, "이미 처리 중인 업로드입니다.");
|
||||
}
|
||||
|
||||
inFlightUploads.add(uploadKey);
|
||||
console.log(`[Upload started] requestId=${requestId} key=${uploadKey}`);
|
||||
|
||||
if (await checkTweetData(body.url, body.selected)) {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
console.log(`[Upload skipped-existing] requestId=${requestId} key=${uploadKey}`);
|
||||
return "이미 저장된 트윗입니다.";
|
||||
}
|
||||
try {
|
||||
const tweetData = await fetchTweetData(body.url);
|
||||
let savedCount = 0;
|
||||
let failedCount = 0;
|
||||
if (tweetData.tweet) {
|
||||
const media = tweetData.tweet.media.photos || [];
|
||||
if (media.length > 0) {
|
||||
const mediaUrls = media.map((m: any) => m.url);
|
||||
// Upload to S3
|
||||
const hasExplicitSelection = body.selected.length > 0;
|
||||
for (const [index, url] of mediaUrls.entries()) {
|
||||
const isSelected = hasExplicitSelection
|
||||
? body.selected[index] === true
|
||||
: true;
|
||||
|
||||
if (!isSelected) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = makeS3FileName(tweetData.tweet.author.id, tweetData.tweet.id, url, index);
|
||||
|
||||
const { media: _media, ...tweetWithoutMedia } = tweetData.tweet;
|
||||
const normalizedTags = normalizeTags(body.tag || ["미분류"]);
|
||||
const result = await uploadAndCreateWithRetry({
|
||||
fileName,
|
||||
mediaUrl: url,
|
||||
mediaIndex: index,
|
||||
createDocument: async () => {
|
||||
await MediaUpload.create({
|
||||
type: "twitter",
|
||||
tweet: tweetWithoutMedia,
|
||||
mediaIndex: index,
|
||||
mediaUrl: `${config.s3.endpoint}/${config.s3.bucket}/${fileName}`,
|
||||
s3Key: fileName,
|
||||
tags: normalizedTags,
|
||||
author: body.author ? body.author : tweetData.tweet.author.name,
|
||||
uploadedBy: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
failedCount += 1;
|
||||
console.error(`[Upload failed] index=${index} url=${url} key=${fileName}`, result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.created) {
|
||||
await saveTags(normalizedTags);
|
||||
savedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedCount === 0 && failedCount === 0) {
|
||||
console.warn("No media uploaded: selected[] did not include any upload target.");
|
||||
}
|
||||
|
||||
console.log(`Saved ${savedCount} media records to MongoDB. Failed: ${failedCount}`);
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.upload.twitter",
|
||||
targetType: "post",
|
||||
summary: `${requester.username} uploaded Twitter media`,
|
||||
detail: {
|
||||
url: body.url,
|
||||
savedCount,
|
||||
failedCount,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return status(400, "트윗에서 이미지를 찾지 못했습니다.");
|
||||
}
|
||||
}
|
||||
console.log(`[Upload finished] requestId=${requestId} key=${uploadKey}`);
|
||||
if (savedCount === 0 && failedCount > 0) {
|
||||
return status(502, "업로드에 실패했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
if (savedCount === 0) {
|
||||
return status(400, "선택된 이미지가 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
savedCount,
|
||||
failedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Upload aborted] requestId=${requestId} key=${uploadKey}`, error);
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
} finally {
|
||||
inFlightUploads.delete(uploadKey);
|
||||
}
|
||||
} else {
|
||||
return status(400, "어...");
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
url: t.String(),
|
||||
tag: t.Optional(t.Array(t.String({ default: "미분류" }))),
|
||||
author: t.Optional(t.String()),
|
||||
selected: t.Array(t.Boolean()),
|
||||
})
|
||||
})
|
||||
|
||||
.post("/bulk-delete", async ({ body, status, jwt, cookie: { mizuki } }) => {
|
||||
const rawToken = mizuki.value;
|
||||
if (typeof rawToken !== "string" || rawToken.length === 0) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const payload = await jwt.verify(rawToken);
|
||||
if (!payload || typeof payload !== "object" || !("id" in payload) || typeof payload.id !== "string") {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const requester = await User.findOne({ userId: payload.id });
|
||||
if (!requester) {
|
||||
return status(401, "Unauthorized");
|
||||
}
|
||||
|
||||
if (requester.role !== "admin" && requester.role !== "writer") {
|
||||
return status(403, "Forbidden");
|
||||
}
|
||||
|
||||
if (!Array.isArray(body.ids) || body.ids.length === 0) {
|
||||
return status(400, "삭제할 게시물이 없습니다.");
|
||||
}
|
||||
|
||||
const result = await MediaUpload.deleteMany({ _id: { $in: body.ids } });
|
||||
|
||||
await createAuditLog({
|
||||
actor: {
|
||||
userId: requester.userId,
|
||||
discordId: requester.discordId,
|
||||
username: requester.username,
|
||||
role: requester.role,
|
||||
},
|
||||
action: "post.bulkDelete",
|
||||
targetType: "post",
|
||||
summary: `${requester.username} deleted multiple posts`,
|
||||
detail: {
|
||||
ids: body.ids,
|
||||
deletedCount: result.deletedCount,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
deletedCount: result.deletedCount,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }),
|
||||
}),
|
||||
})
|
||||
17
apps/backend/src/routes/tweet.ts
Normal file
17
apps/backend/src/routes/tweet.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { fetchTweetData } from "@/lib/tweet";
|
||||
import { Elysia, t } from "elysia";
|
||||
|
||||
export default new Elysia({ prefix: "/tweet" })
|
||||
.get("/fetch", async ({ query, status }) => {
|
||||
try {
|
||||
const tweetData = await fetchTweetData(query.url);
|
||||
return tweetData;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return status(500, "Failed to fetch tweet data");
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
url: t.String(),
|
||||
})
|
||||
})
|
||||
|
|
@ -7,7 +7,7 @@ const nextConfig: NextConfig = {
|
|||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://localhost:1108/:path*',
|
||||
destination: `${process.env.API_BASE_URL}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
|
|||
438
apps/frontend/src/app/add/page.tsx
Normal file
438
apps/frontend/src/app/add/page.tsx
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import Header from "../../components/header";
|
||||
|
||||
type SourceType = "twitter" | "pixiv";
|
||||
|
||||
type PreviewItem = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type TweetApiResponse = {
|
||||
tweet?: {
|
||||
author?: { name?: string };
|
||||
media?: { photos?: Array<{ url?: string }> };
|
||||
};
|
||||
};
|
||||
|
||||
type PixivApiResponse = {
|
||||
image_proxy_urls?: string[];
|
||||
author_name?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
type Me = {
|
||||
role: "admin" | "writer" | "reader";
|
||||
};
|
||||
|
||||
function detectSource(url: string): SourceType | null {
|
||||
if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) {
|
||||
return "twitter";
|
||||
}
|
||||
|
||||
if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) {
|
||||
return "pixiv";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitTags(text: string) {
|
||||
return text
|
||||
.split(/[\n,]/)
|
||||
.map((tag) => tag.trim().replace(/^#/, ""))
|
||||
.filter((tag) => tag.length > 0);
|
||||
}
|
||||
|
||||
export default function AddPage() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [tagsText, setTagsText] = useState("");
|
||||
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
|
||||
const [selected, setSelected] = useState<boolean[]>([]);
|
||||
const [sourceType, setSourceType] = useState<SourceType | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [lastFetchedUrl, setLastFetchedUrl] = useState("");
|
||||
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
|
||||
const [loadingRole, setLoadingRole] = useState(true);
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => selected.filter(Boolean).length,
|
||||
[selected],
|
||||
);
|
||||
const canPreview = url.trim().length > 0 && !loadingPreview;
|
||||
const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0;
|
||||
const tags = useMemo(() => splitTags(tagsText), [tagsText]);
|
||||
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
|
||||
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
if (active) setViewerRole("guest");
|
||||
return;
|
||||
}
|
||||
|
||||
const me = (await response.json()) as Me;
|
||||
if (active) {
|
||||
setViewerRole(me.role);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setViewerRole("guest");
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoadingRole(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadMe();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function resetPreview() {
|
||||
setPreviewItems([]);
|
||||
setSelected([]);
|
||||
setSourceType(null);
|
||||
setLastFetchedUrl("");
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}
|
||||
|
||||
async function fetchPreview(targetUrl?: string) {
|
||||
const trimmedUrl = (targetUrl ?? url).trim();
|
||||
const source = detectSource(trimmedUrl);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!source) {
|
||||
setError("지원하지 않는 URL 형식입니다. Twitter(X) 또는 Pixiv URL을 입력해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingPreview(true);
|
||||
setSourceType(source);
|
||||
|
||||
try {
|
||||
if (source === "twitter") {
|
||||
const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`트위터 미리보기 요청 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TweetApiResponse;
|
||||
const items = (data.tweet?.media?.photos ?? [])
|
||||
.map((photo) => photo.url)
|
||||
.filter((photoUrl): photoUrl is string => typeof photoUrl === "string" && photoUrl.length > 0)
|
||||
.map((photoUrl) => ({ url: photoUrl }));
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error("이미지를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
setPreviewItems(items);
|
||||
setSelected(items.map(() => true));
|
||||
if (!author.trim()) {
|
||||
setAuthor(data.tweet?.author?.name ?? "");
|
||||
}
|
||||
setLastFetchedUrl(trimmedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PixivApiResponse;
|
||||
const items = (data.image_proxy_urls ?? [])
|
||||
.filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0)
|
||||
.map((imageUrl) => ({ url: imageUrl }));
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error("이미지를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
setPreviewItems(items);
|
||||
setSelected(items.map(() => true));
|
||||
if (!author.trim()) {
|
||||
setAuthor(data.author_name ?? "");
|
||||
}
|
||||
if (!tagsText.trim()) {
|
||||
const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", ");
|
||||
setTagsText(pixivTags);
|
||||
}
|
||||
|
||||
setLastFetchedUrl(trimmedUrl);
|
||||
} catch (fetchError) {
|
||||
setPreviewItems([]);
|
||||
setSelected([]);
|
||||
setLastFetchedUrl("");
|
||||
setError(fetchError instanceof Error ? fetchError.message : "미리보기를 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || trimmed === lastFetchedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detectSource(trimmed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void fetchPreview(trimmed);
|
||||
}, 450);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lastFetchedUrl, loadingPreview, url]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!canManagePost) {
|
||||
setError("writer 또는 admin 권한이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceType) {
|
||||
setError("먼저 미리보기를 불러와 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewItems.length === 0) {
|
||||
setError("업로드할 이미지가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
setError("최소 한 장 이상 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tags = splitTags(tagsText);
|
||||
const response = await fetch("/api/post/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
author: author.trim() || undefined,
|
||||
tag: tags.length > 0 ? tags : undefined,
|
||||
selected,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(responseText || `업로드 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
setSuccess(`${selectedCount}개 이미지 업로드를 요청했습니다.`);
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
|
||||
<Header />
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="border-b border-border/70 pb-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-wide">Add Post</h1>
|
||||
<p className="mt-1 text-sm text-foreground/50">아래와 같은 유형의 URL을 지원해요!</p>
|
||||
<ul className="text-sm text-foreground/50">
|
||||
<li> - https://x.com, https://twitter.com (기타 FxEmbed URL)</li>
|
||||
<li> - https://pixiv.net (#R-18 태그가 들어갈 시 거부)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="border-l border-border/80 pl-3 text-xs text-foreground/70">
|
||||
<p>Source: <span className="font-medium text-foreground/90">{sourceLabel}</span></p>
|
||||
<p className="mt-0.5">Selected: <span className="font-medium text-foreground/90">{selectedCount}/{previewItems.length}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={submit}>
|
||||
{!loadingRole && !canManagePost ? (
|
||||
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
|
||||
if (previewItems.length > 0 || sourceType) {
|
||||
resetPreview();
|
||||
}
|
||||
}}
|
||||
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
|
||||
disabled={loadingPreview}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/55">URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
||||
<input
|
||||
id="author"
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(event) => setAuthor(event.target.value)}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
|
||||
/>
|
||||
<p className="text-xs text-red-600/50">* 자동으로 작가를 가져옵니다. 만약 잘못된 이름이라면 수동으로 수정할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="tags">태그</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
value={tagsText}
|
||||
onChange={(event) => setTagsText(event.target.value)}
|
||||
placeholder=""
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
|
||||
/>
|
||||
<p className="text-xs text-foreground/50">* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
|
||||
|
||||
{loadingPreview ? (
|
||||
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">
|
||||
미리보기를 불러오는 중입니다. 이미지 개수에 따라 몇 초 정도 소요될 수 있습니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
|
||||
<span>source: {sourceLabel}</span>
|
||||
<span>selected: {selectedCount}/{previewItems.length}</span>
|
||||
<span>tags: {tags.length}</span>
|
||||
</div>
|
||||
|
||||
{previewItems.length > 0 ? (
|
||||
<section className="mt-8 border-t border-border/70 pt-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm tracking-wide text-foreground/80">Preview</h2>
|
||||
<div className="flex items-center gap-2 text-xs text-foreground/60">
|
||||
<span>{selectedCount}장 선택됨</span>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-border px-2 py-1 text-xs text-foreground/70 hover:bg-black/5"
|
||||
onClick={() => setSelected(previewItems.map(() => true))}
|
||||
>
|
||||
전체 선택
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-border px-2 py-1 text-xs text-foreground/70 hover:bg-black/5"
|
||||
onClick={() => setSelected(previewItems.map(() => false))}
|
||||
>
|
||||
전체 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{previewItems.map((item, index) => (
|
||||
<label
|
||||
key={`${item.url}-${index}`}
|
||||
className={`group relative block cursor-pointer overflow-hidden border bg-white/20 transition ${selected[index] ? "border-foreground/35" : "border-border"}`}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`preview ${index + 1}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`aspect-4/5 w-full object-cover transition ${selected[index] ? "opacity-100" : "opacity-35 grayscale-20"}`}
|
||||
/>
|
||||
<div className="absolute left-2 top-2 border border-border/60 bg-background/80 px-2 py-1 text-xs backdrop-blur">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected[index] ?? false}
|
||||
onChange={() => {
|
||||
setSelected((current) => current.map((value, i) => (i === index ? !value : value)));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{selected[index] ? (
|
||||
<div className="pointer-events-none absolute right-2 top-2 border border-foreground bg-foreground px-2 py-0.5 text-[11px] text-background">
|
||||
selected
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 truncate border border-white/20 bg-black/65 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100">
|
||||
#{index + 1}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || !canManagePost || loadingRole}
|
||||
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "업로드 중..." : "업로드"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
525
apps/frontend/src/app/dashboard/page.tsx
Normal file
525
apps/frontend/src/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import Header from "../../components/header";
|
||||
|
||||
type Role = "admin" | "writer" | "reader";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
type UserItem = {
|
||||
id: string;
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
role: Role;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type UploadItem = {
|
||||
_id: string;
|
||||
mediaUrl: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
uploadedBy?: {
|
||||
username?: string;
|
||||
role?: Role;
|
||||
};
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type AuditLog = {
|
||||
_id: string;
|
||||
action: string;
|
||||
summary?: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
actor?: {
|
||||
username?: string;
|
||||
role?: string;
|
||||
};
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [pendingRole, setPendingRole] = useState<Record<string, Role>>({});
|
||||
|
||||
const [posts, setPosts] = useState<UploadItem[]>([]);
|
||||
const [selectedPostIds, setSelectedPostIds] = useState<string[]>([]);
|
||||
const [deletingPosts, setDeletingPosts] = useState(false);
|
||||
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const [newDiscordId, setNewDiscordId] = useState("");
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newAvatar, setNewAvatar] = useState("");
|
||||
const [newRole, setNewRole] = useState<Role>("reader");
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const sortedUsers = useMemo(
|
||||
() => [...users].sort((a, b) => a.username.localeCompare(b.username)),
|
||||
[users],
|
||||
);
|
||||
|
||||
function buildPostQuery(page: number, size: number) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("size", String(size));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const meResponse = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!meResponse.ok) {
|
||||
throw new Error("로그인이 필요합니다.");
|
||||
}
|
||||
|
||||
const profile = (await meResponse.json()) as Me;
|
||||
setMe(profile);
|
||||
|
||||
if (profile.role !== "admin") {
|
||||
setUsers([]);
|
||||
setRoles([]);
|
||||
setPosts([]);
|
||||
setAuditLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [usersResponse, rolesResponse, postsResponse, logsResponse] = await Promise.all([
|
||||
fetch("/api/auth/users", { cache: "no-store" }),
|
||||
fetch("/api/auth/roles", { cache: "no-store" }),
|
||||
fetch(`/api/post/list?${buildPostQuery(1, 80)}`, { cache: "no-store" }),
|
||||
fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" }),
|
||||
]);
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
throw new Error(`유저 목록을 불러오지 못했습니다: ${usersResponse.status}`);
|
||||
}
|
||||
|
||||
if (!rolesResponse.ok) {
|
||||
throw new Error(`역할 목록을 불러오지 못했습니다: ${rolesResponse.status}`);
|
||||
}
|
||||
|
||||
if (!postsResponse.ok) {
|
||||
throw new Error(`게시물 목록을 불러오지 못했습니다: ${postsResponse.status}`);
|
||||
}
|
||||
|
||||
if (!logsResponse.ok) {
|
||||
throw new Error(`감사 로그를 불러오지 못했습니다: ${logsResponse.status}`);
|
||||
}
|
||||
|
||||
const usersData = (await usersResponse.json()) as UserItem[];
|
||||
const roleData = (await rolesResponse.json()) as Role[];
|
||||
const postData = (await postsResponse.json()) as UploadItem[];
|
||||
const logData = (await logsResponse.json()) as AuditLog[];
|
||||
|
||||
setUsers(usersData);
|
||||
setRoles(roleData);
|
||||
setPosts(postData);
|
||||
setAuditLogs(logData);
|
||||
setPendingRole(
|
||||
Object.fromEntries(usersData.map((user) => [user.id, user.role])) as Record<string, Role>,
|
||||
);
|
||||
setSelectedPostIds([]);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "대시보드를 불러오지 못했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll();
|
||||
}, []);
|
||||
|
||||
async function updateRole(user: UserItem) {
|
||||
const nextRole = pendingRole[user.id] ?? user.role;
|
||||
if (nextRole === user.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingUserId(user.id);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/role", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId: user.id,
|
||||
role: nextRole,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `역할 변경 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
setUsers((current) => current.map((item) => (item.id === user.id ? { ...item, role: nextRole } : item)));
|
||||
setSuccess(`${user.username} 권한을 ${nextRole}(으)로 변경했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "역할 변경에 실패했습니다.");
|
||||
} finally {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/user", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
discordId: newDiscordId.trim(),
|
||||
username: newUsername.trim(),
|
||||
avatar: newAvatar.trim() || undefined,
|
||||
role: newRole,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `유저 생성 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const created = (await response.json()) as UserItem;
|
||||
setUsers((current) => [created, ...current]);
|
||||
setPendingRole((current) => ({ ...current, [created.id]: created.role }));
|
||||
setNewDiscordId("");
|
||||
setNewUsername("");
|
||||
setNewAvatar("");
|
||||
setNewRole("reader");
|
||||
setSuccess(`${created.username} 유저를 생성했습니다.`);
|
||||
void refreshLogs();
|
||||
} catch (createError) {
|
||||
setError(createError instanceof Error ? createError.message : "유저 생성에 실패했습니다.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/audit-logs?page=1&size=80", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = (await response.json()) as AuditLog[];
|
||||
setAuditLogs(data);
|
||||
} catch {
|
||||
// ignore refresh error
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkDeletePosts() {
|
||||
if (selectedPostIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`선택한 ${selectedPostIds.length}개 게시물을 삭제할까요?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingPosts(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/post/bulk-delete", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ids: selectedPostIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `일괄 삭제 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { deletedCount?: number };
|
||||
setPosts((current) => current.filter((post) => !selectedPostIds.includes(post._id)));
|
||||
setSuccess(`${result.deletedCount ?? selectedPostIds.length}개 게시물을 삭제했습니다.`);
|
||||
setSelectedPostIds([]);
|
||||
void refreshLogs();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "게시물 일괄 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setDeletingPosts(false);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePost(id: string) {
|
||||
setSelectedPostIds((current) => {
|
||||
if (current.includes(id)) {
|
||||
return current.filter((value) => value !== id);
|
||||
}
|
||||
return [...current, id];
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAllPosts() {
|
||||
if (selectedPostIds.length === posts.length) {
|
||||
setSelectedPostIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedPostIds(posts.map((post) => post._id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
|
||||
<Header />
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="space-y-4 border-b border-border/70 pb-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-wide">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">유저/게시물 관리 및 감사 로그</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="text-sm text-foreground/60">불러오는 중...</p> : null}
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
|
||||
|
||||
{!loading && me && !isAdmin ? (
|
||||
<div className="border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
관리자만 접근할 수 있습니다.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{!loading && isAdmin ? (
|
||||
<section className="space-y-4 border-b border-border/70 pb-8">
|
||||
<h2 className="text-lg tracking-wide">User Management</h2>
|
||||
<form className="grid gap-3 border border-border/70 p-4 sm:grid-cols-2 lg:grid-cols-4" onSubmit={createUser}>
|
||||
<div>
|
||||
<label className="block text-xs text-foreground/70" htmlFor="discordId">Discord ID</label>
|
||||
<input
|
||||
id="discordId"
|
||||
type="text"
|
||||
value={newDiscordId}
|
||||
onChange={(event) => setNewDiscordId(event.target.value)}
|
||||
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-foreground/70" htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-foreground/70" htmlFor="avatar">Avatar (optional)</label>
|
||||
<input
|
||||
id="avatar"
|
||||
type="text"
|
||||
value={newAvatar}
|
||||
onChange={(event) => setNewAvatar(event.target.value)}
|
||||
className="mt-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-sm outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-foreground/70" htmlFor="role">Role</label>
|
||||
<select
|
||||
id="role"
|
||||
value={newRole}
|
||||
onChange={(event) => setNewRole(event.target.value as Role)}
|
||||
className="mt-1 w-full border border-border bg-transparent px-2 py-1.5 text-sm"
|
||||
>
|
||||
{(roles.length > 0 ? roles : ["admin", "writer", "reader"]).map((role) => (
|
||||
<option key={role} value={role}>{role}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-2 lg:col-span-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="border border-border bg-foreground px-4 py-2 text-sm text-background disabled:opacity-60"
|
||||
>
|
||||
{creating ? "생성 중..." : "유저 생성"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto border border-border/70">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-black/5 text-foreground/70">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Username</th>
|
||||
<th className="px-3 py-2">Discord ID</th>
|
||||
<th className="px-3 py-2">Role</th>
|
||||
<th className="px-3 py-2">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedUsers.map((user) => (
|
||||
<tr key={user.id} className="border-t border-border/50">
|
||||
<td className="px-3 py-2">{user.username}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{user.discordId}</td>
|
||||
<td className="px-3 py-2">
|
||||
<select
|
||||
value={pendingRole[user.id] ?? user.role}
|
||||
onChange={(event) =>
|
||||
setPendingRole((current) => ({
|
||||
...current,
|
||||
[user.id]: event.target.value as Role,
|
||||
}))
|
||||
}
|
||||
className="border border-border px-2 py-1"
|
||||
>
|
||||
{(roles.length > 0 ? roles : ["admin", "writer", "reader"]).map((role) => (
|
||||
<option key={role} value={role}>{role}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void updateRole(user);
|
||||
}}
|
||||
disabled={savingUserId === user.id || (pendingRole[user.id] ?? user.role) === user.role}
|
||||
className="border border-border px-3 py-1 disabled:opacity-50"
|
||||
>
|
||||
{savingUserId === user.id ? "저장 중..." : "저장"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{!loading && isAdmin ? (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg tracking-wide">Audit Log</h2>
|
||||
<div className="overflow-x-auto border border-border/70">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-black/5 text-foreground/70">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Time</th>
|
||||
<th className="px-3 py-2">Actor</th>
|
||||
<th className="px-3 py-2">Action</th>
|
||||
<th className="px-3 py-2">Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log._id} className="border-t border-border/50">
|
||||
<td className="px-3 py-2 text-xs text-foreground/70">{log.createdAt ? new Date(log.createdAt).toLocaleString() : "-"}</td>
|
||||
<td className="px-3 py-2">{log.actor?.username || "system"}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{log.action}</td>
|
||||
<td className="px-3 py-2">{log.summary || `${log.targetType || "target"}:${log.targetId || "-"}`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!loading && isAdmin ? (
|
||||
<section className="space-y-4 border-b border-border/70 pb-8">
|
||||
<div className="sticky top-2 z-40 -mx-1 flex flex-wrap items-center justify-between gap-3 border border-border/70 bg-background/90 px-3 py-2 backdrop-blur">
|
||||
<h2 className="text-lg tracking-wide">Post Management</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="border border-border px-3 py-1 text-sm"
|
||||
onClick={toggleSelectAllPosts}
|
||||
>
|
||||
{selectedPostIds.length === posts.length && posts.length > 0 ? "전체 해제" : "전체 선택"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-red-300 bg-red-50 px-3 py-1 text-sm text-red-700 disabled:opacity-50"
|
||||
disabled={selectedPostIds.length === 0 || deletingPosts}
|
||||
onClick={() => {
|
||||
void bulkDeletePosts();
|
||||
}}
|
||||
>
|
||||
{deletingPosts ? "삭제 중..." : `선택 삭제 (${selectedPostIds.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{posts.map((post) => {
|
||||
const checked = selectedPostIds.includes(post._id);
|
||||
return (
|
||||
<label
|
||||
key={post._id}
|
||||
className={`group relative block cursor-pointer overflow-hidden border bg-white/20 transition ${checked ? "border-red-500" : "border-border"}`}
|
||||
>
|
||||
<img
|
||||
src={post.mediaUrl}
|
||||
alt={post.author || "post"}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-4/5 w-full object-cover"
|
||||
/>
|
||||
<div className="absolute left-2 top-2 bg-background/90 px-2 py-1 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => togglePost(post._id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/70 px-2 py-1.5 text-[11px] text-white">
|
||||
<p className="truncate">author: {post.author || "unknown"}</p>
|
||||
<p className="truncate">uploader: {post.uploadedBy?.username || "unknown"}</p>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
apps/frontend/src/app/edit/[id]/page.tsx
Normal file
283
apps/frontend/src/app/edit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Header from "../../../components/header";
|
||||
|
||||
type SourceType = "twitter" | "pixiv";
|
||||
|
||||
type PostDetailResponse = {
|
||||
_id: string;
|
||||
type: SourceType;
|
||||
url?: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
mediaUrl?: string;
|
||||
};
|
||||
|
||||
type Me = {
|
||||
role: "admin" | "writer" | "reader";
|
||||
};
|
||||
|
||||
function splitTags(text: string) {
|
||||
return text
|
||||
.split(/[\n,]/)
|
||||
.map((tag) => tag.trim().replace(/^#/, ""))
|
||||
.filter((tag) => tag.length > 0);
|
||||
}
|
||||
|
||||
export default function EditPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id;
|
||||
|
||||
const [sourceType, setSourceType] = useState<SourceType | null>(null);
|
||||
const [url, setUrl] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [tagsText, setTagsText] = useState("");
|
||||
const [currentImage, setCurrentImage] = useState<string | null>(null);
|
||||
const [loadingPost, setLoadingPost] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
|
||||
const [loadingRole, setLoadingRole] = useState(true);
|
||||
|
||||
const tags = useMemo(() => splitTags(tagsText), [tagsText]);
|
||||
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
|
||||
const canSubmit = !loadingPost && !submitting && canManagePost && !loadingRole;
|
||||
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
if (active) setViewerRole("guest");
|
||||
return;
|
||||
}
|
||||
|
||||
const me = (await response.json()) as Me;
|
||||
if (active) {
|
||||
setViewerRole(me.role);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setViewerRole("guest");
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoadingRole(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadMe();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
async function loadPost() {
|
||||
setLoadingPost(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/post/detail/${id}`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`수정 데이터를 불러오지 못했습니다: ${response.status}`);
|
||||
}
|
||||
|
||||
const post = (await response.json()) as PostDetailResponse;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSourceType(post.type);
|
||||
setUrl(post.url?.trim() ?? "");
|
||||
setAuthor(post.author ?? "");
|
||||
setTagsText((post.tags ?? []).join(", "));
|
||||
setCurrentImage(post.mediaUrl ?? null);
|
||||
} catch (loadError) {
|
||||
if (active) {
|
||||
setError(loadError instanceof Error ? loadError.message : "수정 데이터를 불러오지 못했습니다.");
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoadingPost(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadPost();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!id) {
|
||||
setError("포스트 ID를 확인할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canManagePost) {
|
||||
setError("writer 또는 admin 권한이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const normalizedTags = splitTags(tagsText);
|
||||
const response = await fetch(`/api/post/edit/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
author: author.trim() || undefined,
|
||||
tag: normalizedTags.length > 0 ? normalizedTags : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(responseText || `수정 실패: ${response.status}`);
|
||||
}
|
||||
|
||||
setSuccess("게시물 정보를 수정했습니다. 프리뷰 이미지는 변경되지 않습니다.");
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "수정에 실패했습니다.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
|
||||
<Header />
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="border-b border-border/70 pb-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl tracking-wide">Edit Post</h1>
|
||||
<p className="mt-1 text-sm text-foreground/50">프리뷰 이미지는 고정이며, 작가/태그만 수정할 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="border-l border-border/80 pl-3 text-xs text-foreground/70">
|
||||
<p>Source: <span className="font-medium text-foreground/90">{sourceLabel}</span></p>
|
||||
<p className="mt-0.5">Preview: <span className="font-medium text-foreground/90">locked</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={submit}>
|
||||
{!loadingRole && !canManagePost ? (
|
||||
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
수정 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-foreground/80">현재 이미지</p>
|
||||
{currentImage ? (
|
||||
<img
|
||||
src={currentImage}
|
||||
alt="current media"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-4/5 w-full max-w-60 border border-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-4/5 w-full max-w-60 border border-border bg-black/5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL (고정)</label>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm text-foreground/60 outline-none"
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-foreground/55">프리뷰 변경 방지를 위해 URL 수정은 비활성화되어 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
||||
<input
|
||||
id="author"
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(event) => setAuthor(event.target.value)}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="tags">태그</label>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
value={tagsText}
|
||||
onChange={(event) => setTagsText(event.target.value)}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
|
||||
/>
|
||||
<p className="text-xs text-foreground/50">쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success}</p> : null}
|
||||
|
||||
{loadingPost ? (
|
||||
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">
|
||||
데이터를 불러오는 중입니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
|
||||
<span>source: {sourceLabel}</span>
|
||||
<span>preview: locked</span>
|
||||
<span>tags: {tags.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<a
|
||||
href="/"
|
||||
className="border border-border px-4 py-2 text-sm text-foreground/70 transition hover:bg-black/5"
|
||||
>
|
||||
취소
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="border border-border bg-foreground px-4 py-2 text-sm text-background transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "수정 중..." : "수정"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 19 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M60.23 50.47s-10.56 2.98-19.38 8.8c-7.26 4.78-13.83 12.04-13.83 12.04l1.88 17.76L34.1 93l21.81-19.63s3.1-8.1 4.21-13.65c.58-2.86.88-5.66.88-5.66zM69.19 52.69s2.21 8.35 3.02 11.66c.92 3.74 2.69 9.82 2.69 9.82l21.69 15.42 8.2-16.05s-4.61-5.72-7.09-8.71-16.57-12.72-16.57-12.72z" style="fill:#af0c1a"/><path d="M13.32 98.51s.75-6.87 6.39-16.69 8.41-11.53 8.41-11.53 3.26-.76 6.15-1.11c3.12-.38 5.72-.09 5.72-.09s-3.93 4.78-6.15 9.39-3.59 8.97-1.96 9.82c1.62.85 3.43-5 6.92-10.33 3.67-5.62 6.47-8.38 6.47-8.38s2.8.09 5.72.96c3.23.96 5.02 2.6 5.02 2.6s-5.76 15.49-8.92 24.46-7.72 20.87-8.83 21.3-3.04-3.02-4.24-5.75-5.29-17.25-5.55-16.99-15.15 2.34-15.15 2.34M74.89 74.1s2.51-1.69 4.56-2.38c2.05-.68 5.68-1.26 5.68-1.26s2.18 4.26 3.88 7.17c1.71 2.9 4.49 8.88 6.28 8.02 1.79-.85-.51-6.89-1.84-9.56-1.21-2.43-3.33-5.72-3.33-5.72s2.65-.34 7.51.6c4.87.94 7.17 2.56 7.17 2.56s4.5 6.68 6.92 10.76c3.22 5.44 7.26 14.68 7.26 14.68l-17.85 1.46-8.97 19.64s-1.24.08-1.93-.34c-.68-.43-3.91-9.3-5.66-14.5-2.75-8.21-9.68-31.13-9.68-31.13" style="fill:#ff605e"/><path d="M100.1 98.73c-.69.64-7.36 17.21-7.56 17.7-1.03 2.54-2.44 3.12-2.44 3.12s1.21 1.74 3.26 1.14c1.58-.46 9.1-18.27 9.1-18.27s13.59.13 15.57-.73c2.22-.96.92-2.77.92-2.77s-17.93-1.05-18.85-.19M13.35 98.13s4.02-.73 7.72-1.71 8.44-2.37 8.44-2.37 3.74 10.22 4.86 13.65 4.04 11.1 4.04 11.1-1.27 1.37-3.1.24c-1.71-1.06-4.98-10.1-5.84-12.5-1.1-3.04-2.53-7.55-2.53-7.55s-3.88 1.29-7.11 1.88-5.42 1.02-6.28.16c-.86-.85-.2-2.9-.2-2.9M46.36 28.97l-18.55-4.76L14.7 34.87l-5.44 26 2.97 5s3.22 2.46 12.28.44c9.06-2.03 29.52-9.91 29.52-9.91s2.61 1.08 4.95 1.4 6.33-.52 6.33-.52 5.02.82 8.01.61c2.98-.21 4.26-.53 4.26-.53s9.81 5.22 19.61 7.99 17.37 3.4 19.08 3.2c2.66-.32 3.94-4.8 3.94-4.8l-1.07-9.81-14.28-30.7-29.74 8.53-1.81 2.03-7.79-2.12-10.57 2.08-1.33-1.56z" style="fill:#dc0d28"/><path d="M54.59 33.37s1.18-3.66 10.07-3.66c8.29 0 9.41 3.28 9.41 3.28s1.08 7.2.1 13.44c-.87 5.58-3.96 11.41-3.96 11.41s-2.05.51-5.22.6c-2.99.08-6.17-.67-6.17-.67S56 52.8 54.81 46.56c-1.14-6.04-.22-13.19-.22-13.19" style="fill:#ff605e"/><path d="M59.9 45.07c1.7.34 2.67-4.23 3.74-5.48 1.68-1.95 5.66-1.57 5.59-4.18-.05-2.06-7.63-3.01-10.36.81-1.9 2.67-1.68 8.31 1.03 8.85" style="fill:#fcc4bf"/><path d="M75.11 31.77s1.13 1.38 2.13 3.86c.74 1.82 1.54 4.48 1.54 4.48s3.68-3.21 6.83-4.95c3.14-1.74 18.15-7.07 18.54.2.31 5.91-10.75 5.79-15.94 7.9-3.65 1.48-8.62 4.28-8.62 4.28v6.02s2.4 2.56 8.02 3.03 20.03.3 22.87.42c5.91.25 7.98 2.9 7.98 6.05 0 3.14-1.11 4.43-.74 4.84.48.54 2.95-1.43 4.29-6.52s2.37-14.6 1.97-22.75c-.47-9.63-2.37-22.52-6.67-24.92s-16.84.8-24.2 3.95c-12.38 5.3-18 14.11-18 14.11M50.37 39.74s.46-2.37 1.33-4.25c.68-1.47 1.93-3.28 1.93-3.28s-2.36-6.15-11.05-11.14-26.87-8.89-32.66-6.69c-2.88 1.09-5.67 8.48-6.52 19.7-.73 9.74.23 21.4 3.3 27.15 2.31 4.33 8.17 7.55 9.95 5.77.87-.87-5.77-3.89.56-7.87 3.67-2.3 10.69-2.1 16.78-2.5s8.69-.1 11.89-.9 5.19-2 5.19-2-.75-2.5-.9-3.7c-.21-1.64-.34-2.98-.34-2.98s-2.11-1.19-7.65-2.11c-5.43-.91-14.35-.71-14.48-6.69-.17-7.59 8.59-5.69 12.68-4s9.49 4.99 9.99 5.49" style="fill:#ff605e"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
|
|
@ -68,7 +68,7 @@
|
|||
}
|
||||
|
||||
.image-scale:hover {
|
||||
z-index: 10;
|
||||
z-index: 80;
|
||||
box-shadow: 0 15px 45px #0006;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import "react-photo-album/masonry.css";
|
|||
|
||||
export const metadata: Metadata = {
|
||||
title: "Akiyama Mizuki",
|
||||
description: "Gallery",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
},
|
||||
description: "Gallery"
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type Upload = {
|
|||
mediaIndex: number;
|
||||
mediaUrl: string;
|
||||
s3Key: string;
|
||||
author?: string;
|
||||
tweet: {
|
||||
url: string;
|
||||
};
|
||||
|
|
@ -28,9 +29,38 @@ type GalleryPhoto = {
|
|||
key: string;
|
||||
href: string;
|
||||
alt: string;
|
||||
author: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
type Me = {
|
||||
role: "admin" | "writer" | "reader";
|
||||
};
|
||||
|
||||
type ContextMenuState = {
|
||||
x: number;
|
||||
y: number;
|
||||
photo: GalleryPhoto;
|
||||
};
|
||||
|
||||
const DEFAULT_LIST_SIZE = 8;
|
||||
const EXTRA_PREFETCH_ROWS = 2;
|
||||
|
||||
function getColumnsForWidth(containerWidth: number) {
|
||||
if (containerWidth < 520) return 2;
|
||||
if (containerWidth < 900) return 3;
|
||||
if (containerWidth < 1280) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
function calculateListSize(viewportWidth: number, viewportHeight: number) {
|
||||
const columns = getColumnsForWidth(viewportWidth);
|
||||
const columnWidth = viewportWidth / columns;
|
||||
// grid item uses aspect-4/5, so height is width * (5 / 4)
|
||||
const itemHeight = columnWidth * (5 / 4);
|
||||
const rowsInViewport = Math.ceil(viewportHeight / itemHeight);
|
||||
const size = columns * (rowsInViewport + EXTRA_PREFETCH_ROWS);
|
||||
return Math.max(DEFAULT_LIST_SIZE, size);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [uploads, setUploads] = useState<Upload[]>([]);
|
||||
|
|
@ -43,12 +73,27 @@ export default function App() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
|
||||
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<GalleryPhoto | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [listSize, setListSize] = useState<number>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return DEFAULT_LIST_SIZE;
|
||||
}
|
||||
return calculateListSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const isFetchingMoreRef = useRef(false);
|
||||
const loadMoreRef = useRef<() => Promise<void>>(async () => { });
|
||||
const wasIntersectingRef = useRef(false);
|
||||
|
||||
function buildQuery(page: number, queryTags: string[]) {
|
||||
function buildQuery(page: number, queryTags: string[], size?: number) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
if (typeof size === "number") {
|
||||
params.set("size", String(size));
|
||||
}
|
||||
for (const tag of queryTags) {
|
||||
params.append("tags", tag);
|
||||
}
|
||||
|
|
@ -69,7 +114,7 @@ export default function App() {
|
|||
|
||||
async function loadTags() {
|
||||
try {
|
||||
const tagsResponse = await fetch("/api/tags", { cache: "no-store" });
|
||||
const tagsResponse = await fetch("/api/post/tags", { cache: "no-store" });
|
||||
if (!tagsResponse.ok) {
|
||||
throw new Error(`Failed to load tags: ${tagsResponse.status}`);
|
||||
}
|
||||
|
|
@ -92,6 +137,119 @@ export default function App() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
if (active) setViewerRole("guest");
|
||||
return;
|
||||
}
|
||||
|
||||
const me = (await response.json()) as Me;
|
||||
if (active) {
|
||||
setViewerRole(me.role);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setViewerRole("guest");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadMe();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function closeContextMenu() {
|
||||
setContextMenu(null);
|
||||
}
|
||||
|
||||
function closeOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setContextMenu(null);
|
||||
if (!isDeleting) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", closeContextMenu);
|
||||
document.addEventListener("scroll", closeContextMenu, true);
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("click", closeContextMenu);
|
||||
document.removeEventListener("scroll", closeContextMenu, true);
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [isDeleting]);
|
||||
|
||||
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
|
||||
|
||||
async function deletePhoto(photo: GalleryPhoto) {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const response = await fetch(`/api/post/delete/${photo.key}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete post: ${response.status}`);
|
||||
}
|
||||
|
||||
setUploads((current) => current.filter((upload) => upload._id !== photo.key));
|
||||
setTotal((current) => Math.max(0, current - 1));
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Failed to delete post");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyImage(photo: GalleryPhoto) {
|
||||
try {
|
||||
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
|
||||
const response = await fetch(photo.src, { cache: "no-store" });
|
||||
const blob = await response.blob();
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || "image/png"]: blob })]);
|
||||
} else if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(photo.src);
|
||||
}
|
||||
} catch {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(photo.src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveImage(photo: GalleryPhoto) {
|
||||
const link = document.createElement("a");
|
||||
link.href = photo.src;
|
||||
link.download = `${photo.key}.jpg`;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function updateListSize() {
|
||||
const nextSize = calculateListSize(window.innerWidth, window.innerHeight);
|
||||
setListSize((current) => (current === nextSize ? current : nextSize));
|
||||
}
|
||||
|
||||
updateListSize();
|
||||
window.addEventListener("resize", updateListSize);
|
||||
return () => window.removeEventListener("resize", updateListSize);
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isFetchingMoreRef.current || loading || loadingMore || !hasMore) {
|
||||
return;
|
||||
|
|
@ -102,8 +260,8 @@ export default function App() {
|
|||
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const query = buildQuery(nextPage, selectedTags);
|
||||
const response = await fetch(`/api/list?${query}`, { cache: "no-store" });
|
||||
const query = buildQuery(nextPage, selectedTags, listSize);
|
||||
const response = await fetch(`/api/post/list?${query}`, { cache: "no-store" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load more gallery: ${response.status}`);
|
||||
|
|
@ -120,7 +278,7 @@ export default function App() {
|
|||
const existingIds = new Set(current.map((item) => item._id));
|
||||
const appended = data.filter((item) => !existingIds.has(item._id));
|
||||
const merged = [...current, ...appended];
|
||||
setHasMore(merged.length < total && data.length >= PAGE_SIZE);
|
||||
setHasMore(merged.length < total && data.length >= listSize);
|
||||
return merged;
|
||||
});
|
||||
|
||||
|
|
@ -132,7 +290,11 @@ export default function App() {
|
|||
isFetchingMoreRef.current = false;
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [hasMore, loading, loadingMore, page, selectedTags, total]);
|
||||
}, [hasMore, listSize, loading, loadingMore, page, selectedTags, total]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMoreRef.current = loadMore;
|
||||
}, [loadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || loading) {
|
||||
|
|
@ -146,16 +308,30 @@ export default function App() {
|
|||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void loadMore();
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isIntersecting && !wasIntersectingRef.current) {
|
||||
wasIntersectingRef.current = true;
|
||||
void loadMoreRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.isIntersecting) {
|
||||
wasIntersectingRef.current = false;
|
||||
}
|
||||
},
|
||||
{ rootMargin: "800px 0px" },
|
||||
{ rootMargin: "200px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, loadMore, loading]);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
wasIntersectingRef.current = false;
|
||||
};
|
||||
}, [hasMore, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
|
@ -168,10 +344,10 @@ export default function App() {
|
|||
setHasMore(true);
|
||||
isFetchingMoreRef.current = false;
|
||||
setError(null);
|
||||
const query = buildQuery(1, selectedTags);
|
||||
const query = buildQuery(1, selectedTags, listSize);
|
||||
const [listResponse, totalResponse] = await Promise.all([
|
||||
fetch(`/api/list?${query}`, { cache: "no-store" }),
|
||||
fetch(`/api/total?${query}`, { cache: "no-store" }),
|
||||
fetch(`/api/post/list?${query}`, { cache: "no-store" }),
|
||||
fetch(`/api/post/total?${buildQuery(1, selectedTags)}`, { cache: "no-store" }),
|
||||
]);
|
||||
|
||||
if (!listResponse.ok) {
|
||||
|
|
@ -212,7 +388,7 @@ export default function App() {
|
|||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedTags]);
|
||||
}, [listSize, selectedTags]);
|
||||
|
||||
const items = useMemo(() => uploads.filter((upload) => Boolean(upload.mediaUrl)), [uploads]);
|
||||
|
||||
|
|
@ -239,6 +415,7 @@ export default function App() {
|
|||
key: upload._id,
|
||||
href: upload.tweet.url,
|
||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||
author: upload.author?.trim() || "unknown",
|
||||
});
|
||||
};
|
||||
image.onerror = () => {
|
||||
|
|
@ -249,6 +426,7 @@ export default function App() {
|
|||
key: upload._id,
|
||||
href: upload.mediaUrl,
|
||||
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
|
||||
author: upload.author?.trim() || "unknown",
|
||||
});
|
||||
};
|
||||
}),
|
||||
|
|
@ -312,41 +490,155 @@ export default function App() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
<MasonryPhotoAlbum
|
||||
<MasonryPhotoAlbum<GalleryPhoto>
|
||||
photos={photos}
|
||||
spacing={0}
|
||||
columns={(containerWidth) => {
|
||||
if (containerWidth < 520) return 2;
|
||||
if (containerWidth < 900) return 3;
|
||||
if (containerWidth < 1280) return 4;
|
||||
return 5;
|
||||
columns={(containerWidth) => getColumnsForWidth(containerWidth)}
|
||||
render={{
|
||||
photo: ({ onClick }, { photo }) => (
|
||||
<a
|
||||
key={photo.key}
|
||||
href={photo.href}
|
||||
onClick={onClick}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const safeX = Math.min(event.clientX, window.innerWidth - 200);
|
||||
const safeY = Math.min(event.clientY, window.innerHeight - 220);
|
||||
setContextMenu({ x: Math.max(12, safeX), y: Math.max(12, safeY), photo });
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative block overflow-hidden image-scale"
|
||||
title={`작가 ${photo.author}`}
|
||||
>
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="block w-full"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-3 bottom-3 translate-y-3 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100">
|
||||
<div className="inline-flex max-w-full items-center rounded-t-sm rounded-b-lg bg-black/72 px-3 py-1.5 text-xs text-white backdrop-blur-sm">
|
||||
<span className="truncate">© {photo.author}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
componentsProps={{
|
||||
container: { className: "!w-full" },
|
||||
image: {
|
||||
loading: "lazy",
|
||||
decoding: "async",
|
||||
className: "block w-full",
|
||||
},
|
||||
link: {
|
||||
className: "block overflow-hidden image-scale",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
|
||||
{loadingMore ? (
|
||||
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-more-${index}`}
|
||||
className="aspect-4/5 animate-pulse bg-black/8"
|
||||
/>
|
||||
))}
|
||||
)
|
||||
}
|
||||
</main >
|
||||
{contextMenu ? (
|
||||
<div
|
||||
className="fixed z-100 min-w-44 overflow-hidden rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canManagePost ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
|
||||
onClick={() => {
|
||||
window.location.href = `/edit/${contextMenu.photo.key}`;
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setDeleteTarget(contextMenu.photo);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
|
||||
onClick={async () => {
|
||||
await copyImage(contextMenu.photo);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
이미지 복사
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded px-3 py-2 text-left text-sm hover:bg-black/5"
|
||||
onClick={() => {
|
||||
saveImage(contextMenu.photo);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
이미지 저장
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
|
||||
{
|
||||
loadingMore ? (
|
||||
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={`loading-more-${index}`}
|
||||
className="aspect-4/5 animate-pulse bg-black/8"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{deleteTarget ? (
|
||||
<div
|
||||
className="fixed inset-0 z-120 flex items-center justify-center bg-black/35 px-4"
|
||||
onClick={() => {
|
||||
if (!isDeleting) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-xl border border-border bg-background p-5 shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<p className="text-base font-semibold text-foreground">게시물을 삭제할까요?</p>
|
||||
<p className="mt-2 text-sm text-foreground/70">삭제 후에는 되돌릴 수 없습니다.</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-4 py-2 text-sm text-foreground/70 hover:bg-black/5 disabled:opacity-50"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void deletePhoto(deleteTarget);
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
discordId: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
role: "admin" | "writer" | "reader";
|
||||
};
|
||||
|
||||
function getAvatarUrl(me: Me) {
|
||||
if (!me.avatar) {
|
||||
return `https://cdn.discordapp.com/embed/avatars/${Number(me.discordId) % 5}.png`;
|
||||
}
|
||||
|
||||
if (me.avatar.startsWith("http://") || me.avatar.startsWith("https://")) {
|
||||
return me.avatar;
|
||||
}
|
||||
|
||||
const ext = me.avatar.startsWith("a_") ? "gif" : "png";
|
||||
return `https://cdn.discordapp.com/avatars/${me.discordId}/${me.avatar}.${ext}?size=128`;
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 404) {
|
||||
if (active) setMe(null);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Failed to load profile: ${response.status}`);
|
||||
}
|
||||
|
||||
const profile = await response.json() as Me;
|
||||
if (active) {
|
||||
setMe(profile);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setMe(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadMe();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (!menuRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!menuRef.current.contains(event.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
});
|
||||
} finally {
|
||||
setMenuOpen(false);
|
||||
setMe(null);
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
|
||||
<header className="relative z-60 flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
|
||||
<a href="/" className="text-2xl">🎀</a>
|
||||
<div className="flex items-center" id="menu">
|
||||
<a href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</a>
|
||||
<a href="/login" className="text-[16px] text-foreground/50">[ <span className="text-foreground">Login</span> ]</a>
|
||||
</div>
|
||||
{me ? (
|
||||
<div className="relative flex items-center gap-4" id="menu" ref={menuRef}>
|
||||
{me.role === "admin" || me.role === "writer" ? (
|
||||
<a href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</a>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="block"
|
||||
title={`${me.username} (${me.role})`}
|
||||
onClick={() => setMenuOpen((current) => !current)}
|
||||
>
|
||||
<img
|
||||
src={getAvatarUrl(me)}
|
||||
alt={`${me.username} profile`}
|
||||
className="h-8 w-8 rounded-full border border-border/70 object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{menuOpen ? (
|
||||
<div className="absolute right-0 top-full z-70 mt-2 min-w-40 overflow-hidden rounded-lg border border-border bg-background/95 p-1 shadow-xl backdrop-blur">
|
||||
<div className="px-3 py-2 text-xs text-foreground/60">
|
||||
{me.username} ({me.role})
|
||||
</div>
|
||||
{me.role === "admin" ? (
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="block rounded px-3 py-2 text-sm text-foreground/80 hover:bg-black/5"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<a href="/api/auth/discord/login" className="text-[16px] text-foreground/50">[ <span className="text-foreground">Login</span> ]</a>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
8
bun.lock
8
bun.lock
|
|
@ -10,9 +10,11 @@
|
|||
"version": "1.0.50",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1030.0",
|
||||
"@elysiajs/jwt": "^1.4.1",
|
||||
"@elysiajs/openapi": "^1.4.14",
|
||||
"elysia": "latest",
|
||||
"mongoose": "^9.4.1",
|
||||
"ulid": "^3.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
|
|
@ -151,6 +153,8 @@
|
|||
|
||||
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
|
||||
|
||||
"@elysiajs/jwt": ["@elysiajs/jwt@1.4.1", "", { "dependencies": { "jose": "^6.0.11" }, "peerDependencies": { "elysia": ">= 1.4.27" } }, "sha512-Qx28XE7hUf2XK/+HZB+hOBGSq6dPs4u1lk80wNge9jijAfzCwftxjOiAWO57RKYSEMdJTOG1ItEL5fbXjJyWsA=="],
|
||||
|
||||
"@elysiajs/openapi": ["@elysiajs/openapi@1.4.14", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-kWmJWdvP8/LwHwAJXSpz6xFfYUoyUyEPRimEYABuDU1rOnS27Da1u9T2jyU7frOopxKWV/wDfDxMP8z2xdCPJw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||
|
|
@ -427,6 +431,8 @@
|
|||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
|
||||
|
||||
"kareem": ["kareem@3.2.0", "", {}, "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
|
@ -523,6 +529,8 @@
|
|||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"ulid": ["ulid@3.0.2", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue