Add static pages and styles
This commit is contained in:
@@ -21,6 +21,7 @@ import { InvitePage } from '@/pages/InvitePage'
|
||||
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||
import { ProfilePage } from '@/pages/ProfilePage'
|
||||
import { UserProfilePage } from '@/pages/UserProfilePage'
|
||||
import { StaticContentPage } from '@/pages/StaticContentPage'
|
||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
import { TeapotPage } from '@/pages/TeapotPage'
|
||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||
@@ -89,6 +90,11 @@ function App() {
|
||||
{/* Public invite page */}
|
||||
<Route path="invite/:code" element={<InvitePage />} />
|
||||
|
||||
{/* Public static content pages */}
|
||||
<Route path="terms" element={<StaticContentPage />} />
|
||||
<Route path="privacy" element={<StaticContentPage />} />
|
||||
<Route path="page/:key" element={<StaticContentPage />} />
|
||||
|
||||
<Route
|
||||
path="login"
|
||||
element={
|
||||
|
||||
@@ -121,6 +121,10 @@ export const adminApi = {
|
||||
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteContent: async (key: string): Promise<void> => {
|
||||
await client.delete(`/admin/content/${key}`)
|
||||
},
|
||||
}
|
||||
|
||||
// Public content API (no auth required)
|
||||
|
||||
78
frontend/src/components/AnnouncementBanner.tsx
Normal file
78
frontend/src/components/AnnouncementBanner.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { contentApi } from '@/api/admin'
|
||||
import { Megaphone, X } from 'lucide-react'
|
||||
|
||||
const STORAGE_KEY = 'announcement_dismissed'
|
||||
|
||||
export function AnnouncementBanner() {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState<string | null>(null)
|
||||
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const loadAnnouncement = async () => {
|
||||
try {
|
||||
const data = await contentApi.getPublicContent('announcement')
|
||||
// Check if this announcement was already dismissed (by updated_at)
|
||||
const dismissedAt = localStorage.getItem(STORAGE_KEY)
|
||||
if (dismissedAt === data.updated_at) {
|
||||
setContent(null)
|
||||
} else {
|
||||
setContent(data.content)
|
||||
setTitle(data.title)
|
||||
setUpdatedAt(data.updated_at)
|
||||
}
|
||||
} catch {
|
||||
// No announcement or error - don't show
|
||||
setContent(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAnnouncement()
|
||||
}, [])
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (updatedAt) {
|
||||
// Store the updated_at to know which announcement was dismissed
|
||||
// When admin updates announcement, updated_at changes and banner shows again
|
||||
localStorage.setItem(STORAGE_KEY, updatedAt)
|
||||
setContent(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative rounded-xl overflow-hidden bg-gradient-to-r from-accent-500/20 via-purple-500/20 to-pink-500/20 border border-accent-500/30">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
|
||||
title="Скрыть"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 pr-12 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center flex-shrink-0">
|
||||
<Megaphone className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
{title && (
|
||||
<h3 className="font-semibold text-white mb-1">{title}</h3>
|
||||
)}
|
||||
<div
|
||||
className="text-sm text-gray-300"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,36 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { contentApi } from '@/api/admin'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Bot, Bell, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const STORAGE_KEY = 'telegram_banner_dismissed'
|
||||
|
||||
// Default content if not configured in admin
|
||||
const DEFAULT_TITLE = 'Привяжите Telegram-бота'
|
||||
const DEFAULT_DESCRIPTION = 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
|
||||
|
||||
export function TelegramBotBanner() {
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
return sessionStorage.getItem(STORAGE_KEY) === 'true'
|
||||
})
|
||||
const [title, setTitle] = useState(DEFAULT_TITLE)
|
||||
const [description, setDescription] = useState(DEFAULT_DESCRIPTION)
|
||||
|
||||
useEffect(() => {
|
||||
const loadContent = async () => {
|
||||
try {
|
||||
const data = await contentApi.getPublicContent('telegram_bot_info')
|
||||
if (data.title) setTitle(data.title)
|
||||
if (data.content) setDescription(data.content)
|
||||
} catch {
|
||||
// Use defaults if content not found
|
||||
}
|
||||
}
|
||||
loadContent()
|
||||
}, [])
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem(STORAGE_KEY, 'true')
|
||||
@@ -49,10 +69,10 @@ export function TelegramBotBanner() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">
|
||||
Привяжите Telegram-бота
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm max-w-md">
|
||||
Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
|
||||
@@ -234,7 +234,13 @@ export function Layout() {
|
||||
Игровой Марафон © {new Date().getFullYear()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<Link to="/terms" className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Правила
|
||||
</Link>
|
||||
<Link to="/privacy" className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||
Конфиденциальность
|
||||
</Link>
|
||||
<span className="text-neon-500/50">v1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MarathonListItem } from '@/types'
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
|
||||
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
|
||||
import { AnnouncementBanner } from '@/components/AnnouncementBanner'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
@@ -146,6 +147,11 @@ export function MarathonsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Announcement Banner */}
|
||||
<div className="mb-4">
|
||||
<AnnouncementBanner />
|
||||
</div>
|
||||
|
||||
{/* Telegram Bot Banner */}
|
||||
<div className="mb-8">
|
||||
<TelegramBotBanner />
|
||||
|
||||
107
frontend/src/pages/StaticContentPage.tsx
Normal file
107
frontend/src/pages/StaticContentPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useLocation, Link } from 'react-router-dom'
|
||||
import { contentApi } from '@/api/admin'
|
||||
import type { StaticContent } from '@/types'
|
||||
import { GlassCard } from '@/components/ui'
|
||||
import { ArrowLeft, Loader2, FileText } from 'lucide-react'
|
||||
|
||||
// Map routes to content keys
|
||||
const ROUTE_KEY_MAP: Record<string, string> = {
|
||||
'/terms': 'terms_of_service',
|
||||
'/privacy': 'privacy_policy',
|
||||
}
|
||||
|
||||
export function StaticContentPage() {
|
||||
const { key: paramKey } = useParams<{ key: string }>()
|
||||
const location = useLocation()
|
||||
const [content, setContent] = useState<StaticContent | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Determine content key from route or param
|
||||
const contentKey = ROUTE_KEY_MAP[location.pathname] || paramKey
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentKey) return
|
||||
|
||||
const loadContent = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await contentApi.getPublicContent(contentKey)
|
||||
setContent(data)
|
||||
} catch {
|
||||
setError('Контент не найден')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadContent()
|
||||
}, [contentKey])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !content) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<GlassCard className="text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||
<FileText className="w-10 h-10 text-gray-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Страница не найдена</h3>
|
||||
<p className="text-gray-400 mb-6">Запрашиваемый контент не существует</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
На главную
|
||||
</Link>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
На главную
|
||||
</Link>
|
||||
|
||||
<GlassCard>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6">{content.title}</h1>
|
||||
<div
|
||||
className="prose prose-invert prose-gray max-w-none
|
||||
prose-headings:text-white prose-headings:font-semibold
|
||||
prose-p:text-gray-300 prose-p:leading-relaxed
|
||||
prose-a:text-neon-400 prose-a:no-underline hover:prose-a:text-neon-300
|
||||
prose-strong:text-white
|
||||
prose-ul:text-gray-300 prose-ol:text-gray-300
|
||||
prose-li:marker:text-gray-500
|
||||
prose-hr:border-dark-600 prose-hr:my-6
|
||||
prose-img:rounded-xl prose-img:shadow-lg"
|
||||
dangerouslySetInnerHTML={{ __html: content.content }}
|
||||
/>
|
||||
<div className="mt-8 pt-6 border-t border-dark-600 text-sm text-gray-500">
|
||||
Последнее обновление: {new Date(content.updated_at).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { adminApi } from '@/api'
|
||||
import type { StaticContent } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
|
||||
import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||
@@ -28,6 +29,7 @@ export function AdminContentPage() {
|
||||
const [formContent, setFormContent] = useState('')
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
useEffect(() => {
|
||||
loadContents()
|
||||
@@ -101,6 +103,30 @@ export function AdminContentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (content: StaticContent) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить контент?',
|
||||
message: `Вы уверены, что хотите удалить "${content.title}"? Это действие нельзя отменить.`,
|
||||
confirmText: 'Удалить',
|
||||
cancelText: 'Отмена',
|
||||
variant: 'danger',
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await adminApi.deleteContent(content.key)
|
||||
setContents(contents.filter(c => c.id !== content.id))
|
||||
if (editing?.id === content.id) {
|
||||
handleCancel()
|
||||
}
|
||||
toast.success('Контент удалён')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete content:', err)
|
||||
toast.error('Ошибка удаления')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -155,15 +181,28 @@ export function AdminContentPage() {
|
||||
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(content)
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1 ml-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(content)
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(content)
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||
Обновлено: {formatDate(content.updated_at)}
|
||||
|
||||
Reference in New Issue
Block a user