feat: X 카테고리 일정 추가 폼 구현
- 백엔드: /api/admin/x/post-info, /api/admin/x/schedule API 추가 - scraper.js에 fetchSingleTweet 함수 추가 (Nitter로 단일 트윗 조회) - 프론트엔드: XForm 컴포넌트 생성 (게시글 ID 입력 → 미리보기 → 저장) - 일정 추가 폼에서 X 카테고리 분기 추가 - API 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0a73149849
commit
bc3f536ec7
6 changed files with 573 additions and 0 deletions
136
backend/src/routes/admin/x.js
Normal file
136
backend/src/routes/admin/x.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
|
||||
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||
import { formatDate, formatTime } from '../../utils/date.js';
|
||||
import config from '../../config/index.js';
|
||||
|
||||
const X_CATEGORY_ID = 3;
|
||||
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
||||
const DEFAULT_USERNAME = 'realfromis_9';
|
||||
|
||||
/**
|
||||
* X(Twitter) 관련 관리자 라우트
|
||||
*/
|
||||
export default async function xRoutes(fastify) {
|
||||
const { db, meilisearch } = fastify;
|
||||
|
||||
/**
|
||||
* GET /api/admin/x/post-info
|
||||
* X 게시글 정보 조회
|
||||
*/
|
||||
fastify.get('/post-info', {
|
||||
schema: {
|
||||
tags: ['admin/x'],
|
||||
summary: 'X 게시글 정보 조회',
|
||||
security: [{ bearerAuth: [] }],
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
postId: { type: 'string', description: '게시글 ID' },
|
||||
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
|
||||
},
|
||||
required: ['postId'],
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { postId, username = DEFAULT_USERNAME } = request.query;
|
||||
|
||||
// 게시글 ID 유효성 검사
|
||||
if (!/^\d+$/.test(postId)) {
|
||||
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const tweet = await fetchSingleTweet(NITTER_URL, username, postId);
|
||||
|
||||
return {
|
||||
postId: tweet.id,
|
||||
username,
|
||||
text: tweet.text,
|
||||
title: extractTitle(tweet.text),
|
||||
imageUrls: tweet.imageUrls,
|
||||
date: tweet.time ? formatDate(tweet.time) : null,
|
||||
time: tweet.time ? formatTime(tweet.time) : null,
|
||||
postUrl: tweet.url,
|
||||
profile: tweet.profile,
|
||||
};
|
||||
} catch (err) {
|
||||
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/x/schedule
|
||||
* X 일정 저장
|
||||
*/
|
||||
fastify.post('/schedule', {
|
||||
schema: {
|
||||
tags: ['admin/x'],
|
||||
summary: 'X 일정 저장',
|
||||
security: [{ bearerAuth: [] }],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
postId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
imageUrls: { type: 'array', items: { type: 'string' } },
|
||||
date: { type: 'string' },
|
||||
time: { type: 'string' },
|
||||
},
|
||||
required: ['postId', 'title', 'date'],
|
||||
},
|
||||
},
|
||||
preHandler: [fastify.authenticate],
|
||||
}, async (request, reply) => {
|
||||
const { postId, title, content, imageUrls, date, time } = request.body;
|
||||
|
||||
try {
|
||||
// 중복 체크
|
||||
const [existing] = await db.query(
|
||||
'SELECT id FROM schedule_x WHERE post_id = ?',
|
||||
[postId]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' });
|
||||
}
|
||||
|
||||
// schedules 테이블에 저장
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||
[X_CATEGORY_ID, title, date, time || null]
|
||||
);
|
||||
const scheduleId = result.insertId;
|
||||
|
||||
// schedule_x 테이블에 저장
|
||||
await db.query(
|
||||
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
||||
[scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null]
|
||||
);
|
||||
|
||||
// Meilisearch 동기화
|
||||
const [categoryRows] = await db.query(
|
||||
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
||||
[X_CATEGORY_ID]
|
||||
);
|
||||
const category = categoryRows[0] || {};
|
||||
|
||||
await addOrUpdateSchedule(meilisearch, {
|
||||
id: scheduleId,
|
||||
title,
|
||||
date,
|
||||
time: time || '',
|
||||
category_id: X_CATEGORY_ID,
|
||||
category_name: category.name || '',
|
||||
category_color: category.color || '',
|
||||
source_name: '',
|
||||
});
|
||||
|
||||
return { success: true, scheduleId };
|
||||
} catch (err) {
|
||||
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
||||
return reply.code(500).send({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import schedulesRoutes from './schedules/index.js';
|
|||
import statsRoutes from './stats/index.js';
|
||||
import botsRoutes from './admin/bots.js';
|
||||
import youtubeAdminRoutes from './admin/youtube.js';
|
||||
import xAdminRoutes from './admin/x.js';
|
||||
|
||||
/**
|
||||
* 라우트 통합
|
||||
|
|
@ -31,4 +32,7 @@ export default async function routes(fastify) {
|
|||
|
||||
// 관리자 - YouTube 라우트
|
||||
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
|
||||
|
||||
// 관리자 - X 라우트
|
||||
fastify.register(xAdminRoutes, { prefix: '/admin/x' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,58 @@ export function parseTweets(html, username) {
|
|||
return tweets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 단일 트윗 조회
|
||||
*/
|
||||
export async function fetchSingleTweet(nitterUrl, username, postId) {
|
||||
const url = `${nitterUrl}/${username}/status/${postId}`;
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`트윗을 찾을 수 없습니다 (${res.status})`);
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
// 메인 트윗 파싱 (main-tweet 클래스)
|
||||
const mainTweetMatch = html.match(/<div class="main-tweet"[^>]*>([\s\S]*?)<\/div>\s*<div class="replies">/);
|
||||
if (!mainTweetMatch) {
|
||||
throw new Error('트윗 내용을 파싱할 수 없습니다');
|
||||
}
|
||||
|
||||
const container = mainTweetMatch[1];
|
||||
|
||||
// 시간
|
||||
const timeMatch = container.match(/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/);
|
||||
const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
|
||||
|
||||
// 텍스트
|
||||
const contentMatch = container.match(/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/);
|
||||
let text = '';
|
||||
if (contentMatch) {
|
||||
text = contentMatch[1]
|
||||
.replace(/<br\s*\/?>/g, '\n')
|
||||
.replace(/<a[^>]*>([^<]*)<\/a>/g, '$1')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// 이미지
|
||||
const imageUrls = extractImageUrls(container);
|
||||
|
||||
// 프로필 정보
|
||||
const profile = extractProfile(html);
|
||||
|
||||
return {
|
||||
id: postId,
|
||||
time,
|
||||
text,
|
||||
imageUrls,
|
||||
url: `https://x.com/${username}/status/${postId}`,
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nitter에서 트윗 수집 (첫 페이지만)
|
||||
*/
|
||||
|
|
|
|||
44
docs/api.md
44
docs/api.md
|
|
@ -229,6 +229,50 @@ YouTube 일정 저장
|
|||
|
||||
---
|
||||
|
||||
## 관리자 - X (인증 필요)
|
||||
|
||||
### GET /admin/x/post-info
|
||||
X 게시글 정보 조회 (Nitter 스크래핑)
|
||||
|
||||
**Query Parameters:**
|
||||
- `postId` - 게시글 ID (필수)
|
||||
- `username` - 사용자명 (기본: realfromis_9)
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"postId": "1234567890",
|
||||
"username": "realfromis_9",
|
||||
"text": "게시글 전체 내용",
|
||||
"title": "첫 문단 (자동 추출)",
|
||||
"imageUrls": ["https://pbs.twimg.com/media/..."],
|
||||
"date": "2026-01-19",
|
||||
"time": "15:00:00",
|
||||
"postUrl": "https://x.com/realfromis_9/status/1234567890",
|
||||
"profile": {
|
||||
"displayName": "프로미스나인 (fromis_9)",
|
||||
"avatarUrl": "https://..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /admin/x/schedule
|
||||
X 일정 저장
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"postId": "1234567890",
|
||||
"title": "게시글 제목",
|
||||
"content": "게시글 내용",
|
||||
"imageUrls": ["https://..."],
|
||||
"date": "2026-01-19",
|
||||
"time": "15:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 헬스 체크
|
||||
|
||||
### GET /health
|
||||
|
|
|
|||
333
frontend/src/pages/pc/admin/schedule/form/XForm.jsx
Normal file
333
frontend/src/pages/pc/admin/schedule/form/XForm.jsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Twitter,
|
||||
Hash,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Save,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
import Toast from "../../../../../components/Toast";
|
||||
import useToast from "../../../../../hooks/useToast";
|
||||
|
||||
/**
|
||||
* X(Twitter) 일정 추가 폼
|
||||
* - 게시글 ID 입력 시 자동으로 정보 조회
|
||||
*/
|
||||
function XForm() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, setToast } = useToast();
|
||||
|
||||
const [postId, setPostId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [postInfo, setPostInfo] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 게시글 ID 추출 (URL에서도 추출 가능)
|
||||
const extractPostId = (input) => {
|
||||
// 숫자만 있으면 그대로 반환
|
||||
if (/^\d+$/.test(input.trim())) {
|
||||
return input.trim();
|
||||
}
|
||||
// URL에서 추출
|
||||
const match = input.match(/status\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
// X 게시글 정보 조회
|
||||
const fetchPostInfo = async () => {
|
||||
const id = extractPostId(postId);
|
||||
if (!id) {
|
||||
setError("게시글 ID 또는 URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPostInfo(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
const response = await fetch(
|
||||
`/api/admin/x/post-info?postId=${id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "게시글 정보를 가져올 수 없습니다.");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPostInfo(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 입력 후 엔터 키
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
fetchPostInfo();
|
||||
}
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleReset = () => {
|
||||
setPostId("");
|
||||
setPostInfo(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!postInfo) {
|
||||
setError("먼저 게시글 ID를 입력하고 조회해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("adminToken");
|
||||
|
||||
const response = await fetch("/api/admin/x/schedule", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
postId: postInfo.postId,
|
||||
title: postInfo.title,
|
||||
content: postInfo.text,
|
||||
imageUrls: postInfo.imageUrls,
|
||||
date: postInfo.date,
|
||||
time: postInfo.time,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "일정 저장에 실패했습니다.");
|
||||
}
|
||||
|
||||
sessionStorage.setItem(
|
||||
"scheduleToast",
|
||||
JSON.stringify({
|
||||
type: "success",
|
||||
message: "X 일정이 추가되었습니다.",
|
||||
})
|
||||
);
|
||||
navigate("/admin/schedule");
|
||||
} catch (err) {
|
||||
setToast({
|
||||
type: "error",
|
||||
message: err.message,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 게시글 ID 입력 */}
|
||||
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Twitter size={24} className="text-black" />
|
||||
<h2 className="text-lg font-bold text-gray-900">X 게시글</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* ID 입력 필드 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
게시글 ID 또는 URL *
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Hash
|
||||
size={18}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={postId}
|
||||
onChange={(e) => setPostId(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="1234567890 또는 https://x.com/realfromis_9/status/1234567890"
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
disabled={loading || postInfo}
|
||||
/>
|
||||
</div>
|
||||
{!postInfo ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchPostInfo}
|
||||
disabled={loading || !postId.trim()}
|
||||
className="px-6 py-3 bg-black text-white rounded-xl hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
조회 중...
|
||||
</>
|
||||
) : (
|
||||
"조회"
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
다시 입력
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
|
||||
>
|
||||
<AlertCircle size={18} />
|
||||
<span>{error}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 게시글 정보 미리보기 */}
|
||||
{postInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="border border-green-200 bg-green-50 rounded-xl p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Check size={18} className="text-green-500" />
|
||||
<span className="text-sm font-medium text-green-600">
|
||||
게시글 정보를 가져왔습니다
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 프로필 */}
|
||||
{postInfo.profile?.displayName && (
|
||||
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-green-200">
|
||||
{postInfo.profile.avatarUrl && (
|
||||
<img
|
||||
src={postInfo.profile.avatarUrl}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-bold text-gray-900">
|
||||
{postInfo.profile.displayName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">@{postInfo.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제목 (첫 문단) */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-400 mb-1">제목 (자동 추출)</p>
|
||||
<p className="text-lg font-bold text-gray-900">{postInfo.title}</p>
|
||||
</div>
|
||||
|
||||
{/* 전체 내용 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-400 mb-1">전체 내용</p>
|
||||
<p className="text-gray-700 whitespace-pre-wrap text-sm">
|
||||
{postInfo.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 이미지 */}
|
||||
{postInfo.imageUrls?.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-400 mb-2 flex items-center gap-1">
|
||||
<ImageIcon size={12} />
|
||||
이미지 ({postInfo.imageUrls.length}개)
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{postInfo.imageUrls.map((url, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="aspect-square bg-gray-200 rounded-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`이미지 ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜/시간 */}
|
||||
<div className="text-sm text-gray-500">
|
||||
<span className="text-gray-400">게시:</span>{" "}
|
||||
{postInfo.date} {postInfo.time}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/admin/schedule")}
|
||||
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!postInfo || saving}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={18} />
|
||||
추가하기
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default XForm;
|
||||
|
|
@ -6,6 +6,7 @@ import useAdminAuth from "../../../../../hooks/useAdminAuth";
|
|||
import * as categoriesApi from "../../../../../api/admin/categories";
|
||||
import CategorySelector from "./components/CategorySelector";
|
||||
import YouTubeForm from "./YouTubeForm";
|
||||
import XForm from "./XForm";
|
||||
|
||||
// 카테고리 ID 상수
|
||||
const CATEGORY_IDS = {
|
||||
|
|
@ -52,6 +53,9 @@ function ScheduleFormPage() {
|
|||
case CATEGORY_IDS.YOUTUBE:
|
||||
return <YouTubeForm />;
|
||||
|
||||
case CATEGORY_IDS.X:
|
||||
return <XForm />;
|
||||
|
||||
// 다른 카테고리는 기존 폼으로 리다이렉트 (추후 구현)
|
||||
default:
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue