Skip to content

Commit e1a9fca

Browse files
committed
ft: admin feats
1 parent ef07867 commit e1a9fca

9 files changed

Lines changed: 213 additions & 42 deletions

File tree

admin-settings.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
maintenanceMode: false
3+
allowSignups: true
4+
showDaysSinceFirstPost: false
5+
adminNotice: "Добро пожаловать в админ-панель."
6+
---

src/App.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
33
import { ThemeProvider } from './lib/theme';
44
import { LanguageProvider } from './lib/i18n';
55
import { AuthProvider, useAuth } from './lib/auth';
6+
import { AdminSettingsProvider } from './lib/admin';
67
import { RateLimitProvider } from './lib/rateLimit';
78
import { Layout } from './components/Layout';
89
import { AuthPage } from './pages/AuthPage';
@@ -109,11 +110,13 @@ export function App() {
109110
<ThemeProvider>
110111
<LanguageProvider>
111112
<AuthProvider>
112-
<RateLimitProvider>
113-
<BrowserRouter>
114-
<AppRoutes />
115-
</BrowserRouter>
116-
</RateLimitProvider>
113+
<AdminSettingsProvider>
114+
<RateLimitProvider>
115+
<BrowserRouter>
116+
<AppRoutes />
117+
</BrowserRouter>
118+
</RateLimitProvider>
119+
</AdminSettingsProvider>
117120
</AuthProvider>
118121
</LanguageProvider>
119122
</ThemeProvider>

src/components/Layout.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { MessageSquare, LogOut, User as UserIcon } from 'lucide-react';
44
import { ThemeToggle } from './ThemeToggle';
55
import { LanguageToggle } from './LanguageToggle';
66
import { useAuth } from '../lib/auth';
7+
import { useAdminSettings } from '../lib/admin';
78
import { useTranslation } from '../lib/i18n';
89
export function Layout() {
910
const { user, logout } = useAuth();
11+
const { settings } = useAdminSettings();
1012
const { t } = useTranslation();
1113
const navigate = useNavigate();
1214
const handleLogout = () => {
@@ -68,6 +70,18 @@ export function Layout() {
6870
</div>
6971
</header>
7072

73+
{settings.adminNotice ? (
74+
<div className="max-w-5xl mx-auto px-4 py-4 bg-blue-50 dark:bg-blue-900/60 border-b border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100">
75+
{settings.adminNotice}
76+
</div>
77+
) : null}
78+
79+
{settings.maintenanceMode && user?.username !== 'admin' ? (
80+
<div className="max-w-5xl mx-auto px-4 py-4 bg-yellow-100 dark:bg-yellow-900/40 border-b border-yellow-300 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100">
81+
{t('maintenanceBanner')}
82+
</div>
83+
) : null}
84+
7185
<main className="max-w-5xl mx-auto px-4 py-8">
7286
<Outlet />
7387
</main>

src/lib/admin.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React, { createContext, useContext, useEffect, useState } from 'react';
2+
import { getFile, putFile } from './github';
3+
import { parseFrontmatter, stringifyFrontmatter } from './utils';
4+
5+
export interface AdminSettings {
6+
maintenanceMode: boolean;
7+
allowSignups: boolean;
8+
adminNotice: string;
9+
showDaysSinceFirstPost: boolean;
10+
}
11+
12+
export const adminSettingsFile = 'admin-settings.md';
13+
14+
export const defaultAdminSettings: AdminSettings = {
15+
maintenanceMode: false,
16+
allowSignups: true,
17+
adminNotice: 'Добро пожаловать в админ-панель.',
18+
showDaysSinceFirstPost: false
19+
};
20+
21+
export async function loadAdminSettings(): Promise<AdminSettings> {
22+
const file = await getFile(adminSettingsFile);
23+
if (!file) {
24+
return defaultAdminSettings;
25+
}
26+
27+
const { data } = parseFrontmatter<AdminSettings>(file.content);
28+
return { ...defaultAdminSettings, ...data };
29+
}
30+
31+
export async function saveAdminSettings(settings: AdminSettings): Promise<void> {
32+
const content = stringifyFrontmatter(settings, '');
33+
await putFile(adminSettingsFile, content, 'Update admin settings');
34+
}
35+
36+
interface AdminSettingsContextType {
37+
settings: AdminSettings;
38+
setSettings: React.Dispatch<React.SetStateAction<AdminSettings>>;
39+
saveSettings: (settings: AdminSettings) => Promise<void>;
40+
isLoading: boolean;
41+
}
42+
43+
const AdminSettingsContext = createContext<AdminSettingsContextType | null>(null);
44+
45+
export function AdminSettingsProvider({ children }: { children: React.ReactNode }) {
46+
const [settings, setSettings] = useState<AdminSettings>(defaultAdminSettings);
47+
const [isLoading, setIsLoading] = useState(true);
48+
49+
useEffect(() => {
50+
loadAdminSettings()
51+
.then((loadedSettings) => {
52+
setSettings(loadedSettings);
53+
})
54+
.catch((error) => {
55+
console.error('Failed to load admin settings:', error);
56+
})
57+
.finally(() => {
58+
setIsLoading(false);
59+
});
60+
}, []);
61+
62+
const saveSettings = async (updatedSettings: AdminSettings) => {
63+
await saveAdminSettings(updatedSettings);
64+
setSettings(updatedSettings);
65+
};
66+
67+
return (
68+
<AdminSettingsContext.Provider
69+
value={{ settings, setSettings, saveSettings, isLoading }}>
70+
{children}
71+
</AdminSettingsContext.Provider>
72+
);
73+
}
74+
75+
export function useAdminSettings() {
76+
const context = useContext(AdminSettingsContext);
77+
if (!context) {
78+
throw new Error('useAdminSettings must be used within AdminSettingsProvider');
79+
}
80+
return context;
81+
}

src/lib/i18n.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ const translations = {
4949
welcomeTitle: 'Welcome to the Forum Template',
5050
welcomeMessage: 'This is a modern forum application built with React, TypeScript, and Tailwind CSS. You can use this as a template to create your own forum.',
5151
featuresTitle: 'Features',
52-
featuresList: 'User authentication, Markdown support, Dark mode, Mobile responsive, Multi-language support'
52+
featuresList: 'User authentication, Markdown support, Dark mode, Mobile responsive, Multi-language support',
53+
maintenanceMessage: 'The site is currently under maintenance. Please try again later.',
54+
signupsDisabled: 'Registration is temporarily disabled. Please sign in to an existing account.',
55+
adminSettingsLoading: 'Loading admin settings...',
56+
maintenanceBanner: 'Site is in maintenance mode. Some features may be limited.'
5357
},
5458
ru: {
5559
login: 'Войти',
@@ -98,7 +102,11 @@ const translations = {
98102
welcomeTitle: 'Добро пожаловать в шаблон форума',
99103
welcomeMessage: 'Это современное форумное приложение, созданное с помощью React, TypeScript и Tailwind CSS. Вы можете использовать это как шаблон для создания своего собственного форума.',
100104
featuresTitle: 'Возможности',
101-
featuresList: 'Аутентификация пользователей, Поддержка Markdown, Темная тема, Адаптивность для мобильных, Поддержка нескольких языков'
105+
featuresList: 'Аутентификация пользователей, Поддержка Markdown, Темная тема, Адаптивность для мобильных, Поддержка нескольких языков',
106+
maintenanceMessage: 'Сайт находится в режиме обслуживания. Повторите попытку позже.',
107+
signupsDisabled: 'Регистрация временно отключена. Пожалуйста, войдите в существующий аккаунт.',
108+
adminSettingsLoading: 'Загрузка настроек администратора...',
109+
maintenanceBanner: 'Сайт находится в режиме обслуживания. Некоторые функции могут быть ограничены.'
102110
}
103111
};
104112

src/lib/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@ export function normalizeText(text: string): string {
8787
.trim();
8888
}
8989

90+
export function calculateDaysBetween(startIso: string, endIso: string): number {
91+
const start = new Date(startIso);
92+
const end = new Date(endIso);
93+
const diffMs = end.getTime() - start.getTime();
94+
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
95+
}
96+
97+
export function formatDaysCount(days: number): string {
98+
const absDays = Math.abs(days);
99+
const lastTwoDigits = absDays % 100;
100+
const lastDigit = absDays % 10;
101+
102+
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
103+
return `${days} дней`;
104+
}
105+
if (lastDigit === 1) {
106+
return `${days} день`;
107+
}
108+
if (lastDigit >= 2 && lastDigit <= 4) {
109+
return `${days} дня`;
110+
}
111+
return `${days} дней`;
112+
}
113+
90114
// Simple MD5 implementation for Gravatar
91115
export async function generateGravatarUrl(username: string, size: number = 200): Promise<string> {
92116
const email = `${username.toLowerCase()}@gravatar.local`;

src/pages/AdminPage.tsx

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,22 @@
1-
import React, { useEffect, useState } from 'react';
1+
import React, { useState } from 'react';
22
import { ArrowLeft, Save, ShieldCheck, Settings } from 'lucide-react';
33
import { Navigate, useNavigate } from 'react-router-dom';
44
import { useAuth } from '../lib/auth';
5-
6-
interface AdminSettings {
7-
maintenanceMode: boolean;
8-
allowSignups: boolean;
9-
adminNotice: string;
10-
}
11-
12-
const defaultSettings: AdminSettings = {
13-
maintenanceMode: false,
14-
allowSignups: true,
15-
adminNotice: 'Добро пожаловать в админ-панель.',
16-
};
5+
import { useAdminSettings } from '../lib/admin';
176

187
export function AdminPage() {
198
const { user } = useAuth();
209
const navigate = useNavigate();
21-
const [settings, setSettings] = useState<AdminSettings>(defaultSettings);
10+
const { settings, setSettings, saveSettings, isLoading } = useAdminSettings();
2211
const [isSaving, setIsSaving] = useState(false);
2312

24-
useEffect(() => {
25-
const stored = localStorage.getItem('admin-settings');
26-
if (stored) {
27-
try {
28-
setSettings(JSON.parse(stored));
29-
} catch (error) {
30-
console.warn('Invalid admin settings in localStorage:', error);
31-
}
32-
}
33-
}, []);
13+
if (isLoading) {
14+
return (
15+
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
16+
<div className="text-gray-600 dark:text-gray-300">Загрузка настроек администратора...</div>
17+
</div>
18+
);
19+
}
3420

3521
if (!user) {
3622
return <Navigate to="/auth" replace />;
@@ -40,10 +26,15 @@ export function AdminPage() {
4026
return <Navigate to="/" replace />;
4127
}
4228

43-
const handleSave = () => {
29+
const handleSave = async () => {
4430
setIsSaving(true);
45-
localStorage.setItem('admin-settings', JSON.stringify(settings));
46-
window.setTimeout(() => setIsSaving(false), 300);
31+
try {
32+
await saveSettings(settings);
33+
} catch (error) {
34+
console.error('Failed to save admin settings:', error);
35+
} finally {
36+
setIsSaving(false);
37+
}
4738
};
4839

4940
return (
@@ -104,6 +95,21 @@ export function AdminPage() {
10495
className="h-5 w-5 text-blue-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
10596
/>
10697
</label>
98+
99+
<label className="flex items-center justify-between gap-3 p-4 bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700">
100+
<div>
101+
<p className="font-medium">Показывать количество дней от первого поста</p>
102+
<p className="text-sm text-gray-500 dark:text-gray-400">Отображать на постах темы количество дней с момента создания темы.</p>
103+
</div>
104+
<input
105+
type="checkbox"
106+
checked={settings.showDaysSinceFirstPost}
107+
onChange={(event) =>
108+
setSettings({ ...settings, showDaysSinceFirstPost: event.target.checked })
109+
}
110+
className="h-5 w-5 text-blue-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700"
111+
/>
112+
</label>
107113
</div>
108114

109115
<div>

src/pages/AuthPage.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import React, { useEffect, useState } from 'react';
22
import { useNavigate } from 'react-router-dom';
33
import { MessageSquare, Loader2 } from 'lucide-react';
4+
import { getFile } from '../lib/github';
45
import { useAuth } from '../lib/auth';
6+
import { useAdminSettings } from '../lib/admin';
57
import { useTranslation } from '../lib/i18n';
68
import { safeLogError } from '../lib/utils';
79
import { ThemeToggle } from '../components/ThemeToggle';
@@ -13,6 +15,7 @@ export function AuthPage() {
1315
const [isUsernameValid, setIsUsernameValid] = useState(true);
1416
const [usernameError, setUsernameError] = useState('');
1517
const { login, user } = useAuth();
18+
const { settings } = useAdminSettings();
1619
const { t } = useTranslation();
1720
const navigate = useNavigate();
1821

@@ -60,10 +63,25 @@ export function AuthPage() {
6063
if (!validateUsername(username)) {
6164
return;
6265
}
66+
67+
const normalizedUsername = username.trim().toLowerCase();
68+
if (settings.maintenanceMode && normalizedUsername !== 'admin') {
69+
setError(t('maintenanceMessage'));
70+
return;
71+
}
72+
6373
setIsSubmitting(true);
6474
setError('');
6575
try {
66-
await login(username.trim().toLowerCase());
76+
const path = `users/${normalizedUsername}.md`;
77+
const userFile = await getFile(path);
78+
79+
if (!settings.allowSignups && !userFile && normalizedUsername !== 'admin') {
80+
setError(t('signupsDisabled'));
81+
return;
82+
}
83+
84+
await login(normalizedUsername);
6785
navigate('/');
6886
} catch (err: any) {
6987
safeLogError('Login error:', err);

src/pages/TopicPage.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { useAuth } from '../lib/auth';
77
import { getFile } from '../lib/github';
88
import { MarkdownContent } from '../components/MarkdownContent';
99
import { MarkdownEditor } from '../components/MarkdownEditor';
10-
import { normalizeText, generateGravatarUrl, parseFrontmatter, safeLogError } from '../lib/utils';
10+
import { useAdminSettings } from '../lib/admin';
11+
import { calculateDaysBetween, formatDaysCount, normalizeText, generateGravatarUrl, parseFrontmatter, safeLogError } from '../lib/utils';
1112
export function TopicPage() {
1213
const { id } = useParams<{
1314
id: string;
@@ -27,6 +28,7 @@ export function TopicPage() {
2728
const textTimeoutRef = useRef<NodeJS.Timeout | null>(null);
2829
const { t } = useTranslation();
2930
const { user } = useAuth();
31+
const { settings } = useAdminSettings();
3032

3133
useEffect(() => {
3234
if (authorTimeoutRef.current) clearTimeout(authorTimeoutRef.current);
@@ -128,11 +130,14 @@ export function TopicPage() {
128130
const quote = `> ${author} ${t('said')}:\n> ${body.replace(/\n/g, '\n> ')}\n\n`;
129131
setReplyBody(prev => prev + quote);
130132
};
131-
const renderMessage = (message: any, isOriginal: boolean) => (
132-
<div
133-
key={message.id}
134-
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
135-
133+
const renderMessage = (message: any, isOriginal: boolean) => {
134+
const daysSinceFirstPost = calculateDaysBetween(topic?.createdAt || message.createdAt, message.createdAt);
135+
const showDaysLabel = settings.showDaysSinceFirstPost && !isOriginal;
136+
137+
return (
138+
<div
139+
key={message.id}
140+
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
136141
<div className="flex flex-col sm:flex-row">
137142
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 sm:w-48 border-b sm:border-b-0 sm:border-r border-gray-200 dark:border-gray-700 flex flex-col items-center sm:items-start">
138143
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center overflow-hidden mb-2">
@@ -153,6 +158,11 @@ export function TopicPage() {
153158
<Clock className="w-3 h-3" />
154159
{new Date(message.createdAt).toLocaleDateString()}
155160
</span>
161+
{showDaysLabel && (
162+
<span className="text-xs text-gray-600 dark:text-gray-300 mt-2 bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded-full">
163+
{formatDaysCount(daysSinceFirstPost)}
164+
</span>
165+
)}
156166
</div>
157167
<div className="p-6 flex-1">
158168
<MarkdownContent content={message.body} />
@@ -168,7 +178,8 @@ export function TopicPage() {
168178
</div>
169179
</div>
170180
</div>
171-
);
181+
);
182+
};
172183
if (isLoading) {
173184
return (
174185
<div className="flex justify-center items-center py-20">

0 commit comments

Comments
 (0)