feat(ui): общий футер на всех страницах, кроме комнаты
`AppFooter` — версия инстанса из публичного `/api/health` (не требует авторизации, поэтому работает и на экранах входа; поля `version` может не быть — тогда версия не показывается) плюс копирайт с ТЕКУЩИМ годом и ссылкой на профиль автора. Подключён во все раскладки: `page-shell` (календарь, админка, профиль, мои конференции), `AuthLayout` (вход, регистрация, подтверждение email), `join-shell` и `lobby-shell`. В комнате конференции футера нет — там полноэкранное видео. Прежний футер админки (`.admin-footer`) удалён вместе со стилем — дубля не осталось. В брендовой панели экранов auth строка «© 2026 VidConf · self-hosted» ужата до «self-hosted»: год там был захардкожен, а два копирайта на одном экране — дубль.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { LogoMark } from '@/components/ui/LogoMark'
|
import { LogoMark } from '@/components/ui/LogoMark'
|
||||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||||
import '@/styles/auth.css'
|
import '@/styles/auth.css'
|
||||||
@@ -41,11 +42,18 @@ export function AuthLayout({ children }: { children: ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
|
<p className="hint-msg" style={{ position: 'relative', zIndex: 1 }}>
|
||||||
© 2026 VidConf · self-hosted
|
self-hosted
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="form-panel">{children}</main>
|
{/* Копирайт с годом и версией инстанса — общий `AppFooter` (он же на всех
|
||||||
|
остальных страницах). Из брендовой панели строку «© 2026 VidConf ·
|
||||||
|
self-hosted» при этом ужали до «self-hosted»: год там был
|
||||||
|
захардкожен, а два копирайта на одном экране — дубль. */}
|
||||||
|
<main className="form-panel">
|
||||||
|
{children}
|
||||||
|
<AppFooter />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
34
frontend/src/components/layout/AppFooter.tsx
Normal file
34
frontend/src/components/layout/AppFooter.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getHealth } from '@/api/health'
|
||||||
|
import '@/styles/footer.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий футер приложения: версия инстанса и копирайт со ссылкой на автора.
|
||||||
|
*
|
||||||
|
* Ставится на всех страницах, КРОМЕ комнаты конференции (`RoomPage`) — там
|
||||||
|
* полноэкранное видео и тулбар, футеру места нет. Раскладок в приложении
|
||||||
|
* несколько (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`), поэтому
|
||||||
|
* компонент рендерит только собственный `<footer>`, а привязку к конкретной
|
||||||
|
* раскладке делает CSS (см. `styles/footer.css`).
|
||||||
|
*
|
||||||
|
* Версия — из публичного `GET /api/health` (авторизации не требует, годится и
|
||||||
|
* для экранов входа). Поле `version` опционально: если backend его не отдал,
|
||||||
|
* версию просто не показываем (см. `HealthOut.version`). Ключ запроса тот же
|
||||||
|
* `['health']`, что и у админки, — react-query отдаст общий закэшированный
|
||||||
|
* результат, лишнего запроса не будет.
|
||||||
|
*
|
||||||
|
* Год — текущий, из `Date` (не захардкожен): страница живёт в браузере
|
||||||
|
* пользователя и не должна устаревать 1 января.
|
||||||
|
*/
|
||||||
|
export function AppFooter() {
|
||||||
|
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="app-footer">
|
||||||
|
VidConf{health?.version ? ` · v${health.version}` : ''} · © {new Date().getFullYear()} · by{' '}
|
||||||
|
<a href="https://github.com/MaxRonzhin" target="_blank" rel="noopener">
|
||||||
|
MaxRonzhin
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
|
import { AdminConferencesTab } from '@/components/admin/AdminConferencesTab'
|
||||||
import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
|
import { AdminUsersTab } from '@/components/admin/AdminUsersTab'
|
||||||
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
|
import { AdminTeamsTab } from '@/components/admin/AdminTeamsTab'
|
||||||
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
|
import { AdminSettingsTab } from '@/components/admin/AdminSettingsTab'
|
||||||
import { getHealth } from '@/api/health'
|
|
||||||
import '@/styles/lobby.css'
|
import '@/styles/lobby.css'
|
||||||
import '@/styles/calendar.css'
|
import '@/styles/calendar.css'
|
||||||
import '@/styles/admin.css'
|
import '@/styles/admin.css'
|
||||||
@@ -30,10 +29,6 @@ const TABS: { value: AdminTab; label: string }[] = [
|
|||||||
*/
|
*/
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const [tab, setTab] = useState<AdminTab>('conferences')
|
const [tab, setTab] = useState<AdminTab>('conferences')
|
||||||
// Версия инстанса для футера — поле `version` в `/api/health`;
|
|
||||||
// если его нет в ответе, футер версию просто не показывает
|
|
||||||
// (см. `HealthOut.version`).
|
|
||||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, retry: false })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page page-shell">
|
<div className="admin-page page-shell">
|
||||||
@@ -63,11 +58,8 @@ export function AdminPage() {
|
|||||||
{tab === 'users' && <AdminUsersTab />}
|
{tab === 'users' && <AdminUsersTab />}
|
||||||
{tab === 'teams' && <AdminTeamsTab />}
|
{tab === 'teams' && <AdminTeamsTab />}
|
||||||
{tab === 'settings' && <AdminSettingsTab />}
|
{tab === 'settings' && <AdminSettingsTab />}
|
||||||
|
|
||||||
<footer className="admin-footer">
|
|
||||||
VidConf{health?.version ? ` · v${health.version}` : ''}
|
|
||||||
</footer>
|
|
||||||
</main>
|
</main>
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
import { ConferenceCalendar, type CalendarViewMode } from '@/components/calendar/ConferenceCalendar'
|
||||||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||||||
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
import { ConferenceOccurrenceDialog } from '@/components/calendar/ConferenceOccurrenceDialog'
|
||||||
@@ -102,6 +103,7 @@ export function CalendarPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<ConferenceFormModal
|
<ConferenceFormModal
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@/api/conferences'
|
} from '@/api/conferences'
|
||||||
import { ApiError, errorDetail } from '@/api/client'
|
import { ApiError, errorDetail } from '@/api/client'
|
||||||
import { useAuth } from '@/auth/useAuth'
|
import { useAuth } from '@/auth/useAuth'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { LogoMark } from '@/components/ui/LogoMark'
|
import { LogoMark } from '@/components/ui/LogoMark'
|
||||||
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
import { ThemeToggle } from '@/components/ui/ThemeToggle'
|
||||||
import { parseJoinQuery } from '@/lib/parseJoinQuery'
|
import { parseJoinQuery } from '@/lib/parseJoinQuery'
|
||||||
@@ -409,6 +410,7 @@ export function JoinPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Link2, User, Video } from 'lucide-react'
|
import { Calendar, Link2, User, Video } from 'lucide-react'
|
||||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { LogoMedallion } from '@/components/ui/LogoMark'
|
import { LogoMedallion } from '@/components/ui/LogoMark'
|
||||||
import { createConference } from '@/api/conferences'
|
import { createConference } from '@/api/conferences'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
@@ -113,6 +114,7 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
|
import { AlertTriangle, CheckCircle2, Clock, Lock, Pencil, Play, Repeat } from 'lucide-react'
|
||||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
import { ConferenceFormCard } from '@/components/calendar/ConferenceFormCard'
|
||||||
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
import { ConferenceHoverCard } from '@/components/ui/ConferenceHoverCard'
|
||||||
import { CopyPill } from '@/components/ui/CopyPill'
|
import { CopyPill } from '@/components/ui/CopyPill'
|
||||||
@@ -212,6 +213,7 @@ export function MyConferencesPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
|
||||||
{editingConference && (
|
{editingConference && (
|
||||||
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>
|
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={() => setEditingConference(null)}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRef, useState, type FormEvent } from 'react'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Trash2, Upload } from 'lucide-react'
|
import { Trash2, Upload } from 'lucide-react'
|
||||||
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
import { ShellTopbar } from '@/components/layout/ShellTopbar'
|
||||||
|
import { AppFooter } from '@/components/layout/AppFooter'
|
||||||
import { Avatar } from '@/components/ui/Avatar'
|
import { Avatar } from '@/components/ui/Avatar'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
@@ -271,6 +272,7 @@ export function ProfilePage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<AppFooter />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,10 +168,8 @@
|
|||||||
.admin-page .settings-grid { grid-template-columns: 1fr; }
|
.admin-page .settings-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Футер админки ----------
|
/* Футер админки (`.admin-footer`) убран в 0.0.6: вместо него общий для всех
|
||||||
Версия инстанса из `/api/health` (см. AdminPage.tsx) — до появления поля
|
страниц компонент `AppFooter` (см. `styles/footer.css`). */
|
||||||
на backend строка сокращается до одного «VidConf» без версии. */
|
|
||||||
.admin-page .admin-footer { margin-top: var(--space-6); padding-top: var(--space-4); border-top: 1px solid var(--color-border); font: var(--text-caption); color: var(--color-ink-400); text-align: center; }
|
|
||||||
|
|
||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
* Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким
|
* Тёмная тема: заливка var(--color-ink-700) (индиго) сочеталась с жёстким
|
||||||
|
|||||||
38
frontend/src/styles/footer.css
Normal file
38
frontend/src/styles/footer.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
* VidConf — общий футер приложения (см. `components/layout/AppFooter.tsx`).
|
||||||
|
*
|
||||||
|
* Файл импортируется самим компонентом, а не страницами: футер стоит в четырёх
|
||||||
|
* разных раскладках (`page-shell`, `AuthLayout`, `join-shell`, `lobby-shell`),
|
||||||
|
* и таскать импорт по всем страницам смысла нет.
|
||||||
|
*
|
||||||
|
* Футер — обычный блок в потоке (не `position: fixed`), поэтому контент он
|
||||||
|
* не перекрывает ни на какой высоте экрана.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
padding: var(--space-5) var(--space-4);
|
||||||
|
font: var(--text-caption);
|
||||||
|
color: var(--color-ink-400);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.app-footer a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.app-footer a:hover { color: var(--color-ink-700); }
|
||||||
|
|
||||||
|
/* Оболочка страниц (`page-shell`): выравниваем футер по ширине контента
|
||||||
|
(`main.page-main` — max-width 1440 + боковые отступы) и отделяем линией,
|
||||||
|
как это делал прежний футер админки. */
|
||||||
|
.page-shell .app-footer {
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-4) var(--space-8) var(--space-6);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Экраны auth: футер — последний элемент правой колонки с формой, поэтому
|
||||||
|
ограничиваем его шириной самой формы (`.screen-block`, 440px), иначе
|
||||||
|
центрированная строка «уезжала» бы вправо от неё. */
|
||||||
|
.form-panel .app-footer { max-width: 440px; width: 100%; padding-inline: 0; margin-top: var(--space-8); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.page-shell .app-footer { padding: var(--space-4) var(--space-4) var(--space-5); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user