feat: add installable pwa baseline

This commit is contained in:
lizerk 2026-04-16 20:04:21 +03:00
parent 7ccf69e8ef
commit 2e4975a901
20 changed files with 411 additions and 50 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -7,6 +7,16 @@
*/
const SW_VERSION = '2.0.0';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
'/manifest.webmanifest',
'/logo.png',
'/logo.webp',
'/icon-192.png',
'/icon-512.png',
'/icon-512-maskable.png',
'/apple-touch-icon.png',
];
// Настройки по умолчанию для уведомлений
const DEFAULT_NOTIFICATION_OPTIONS = {
@ -22,8 +32,15 @@ const DEFAULT_NOTIFICATION_OPTIONS = {
*/
self.addEventListener('install', (event) => {
console.log('[SW] Service Worker installing, version:', SW_VERSION);
// Немедленно активировать новую версию
self.skipWaiting();
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.catch((error) => {
console.error('[SW] Failed to precache static assets:', error);
})
.then(() => self.skipWaiting())
);
});
/**
@ -31,8 +48,56 @@ self.addEventListener('install', (event) => {
*/
self.addEventListener('activate', (event) => {
console.log('[SW] Service Worker activated, version:', SW_VERSION);
// Захватить все клиенты без перезагрузки
event.waitUntil(self.clients.claim());
event.waitUntil(
caches.keys().then((cacheKeys) =>
Promise.all(
cacheKeys.map((cacheKey) => {
if (cacheKey !== STATIC_CACHE && cacheKey.startsWith('eusrr-static-')) {
return caches.delete(cacheKey);
}
return Promise.resolve(false);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
const isApiRequest = url.pathname.startsWith('/api/');
const isStaticAsset =
url.pathname.startsWith('/_next/static/') ||
PRECACHE_URLS.includes(url.pathname) ||
['image', 'style', 'script', 'font', 'worker'].includes(request.destination);
if (isApiRequest || !isStaticAsset) {
return;
}
event.respondWith(
caches.open(STATIC_CACHE).then(async (cache) => {
const cachedResponse = await cache.match(request);
if (cachedResponse) {
return cachedResponse;
}
const networkResponse = await fetch(request);
if (networkResponse.ok) {
cache.put(request, networkResponse.clone()).catch(() => undefined);
}
return networkResponse;
})
);
});
/**

View File

@ -18,13 +18,31 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "EUSRR - Корпоративная система",
description: "Корпоративная система управления EUSRR",
applicationName: "EUSRR",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "EUSRR",
},
icons: {
icon: [
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
},
};
export const viewport: Viewport = {
width: 'device-width',
width: "device-width",
initialScale: 1,
maximumScale: 1,
interactiveWidget: 'resizes-content',
interactiveWidget: "resizes-content",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f4f8fc" },
{ media: "(prefers-color-scheme: dark)", color: "#111923" },
],
};
export default function RootLayout({

View File

@ -0,0 +1,32 @@
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "EUSRR",
short_name: "EUSRR",
description: "Корпоративная система управления EUSRR",
start_url: "/",
scope: "/",
display: "standalone",
background_color: "#111923",
theme_color: "#111923",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/icon-512-maskable.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
};
}

View File

@ -7,6 +7,7 @@ import {
Bell,
Check,
Clock3,
Download,
Eye,
EyeOff,
KeyRound,
@ -21,6 +22,7 @@ import {
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { usePwa } from "@/contexts/PwaContext";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import {
formatSessionDateTime,
@ -126,6 +128,7 @@ function SectionCard({
export default function SettingsPage() {
const { mobileNavPlacement, setMobileNavPlacement } = useMobileNavPlacement();
const { canInstall, install, isInstalled, isRegistrationReady } = usePwa();
const {
activeVerbCount,
avatarInputRef,
@ -400,6 +403,61 @@ export default function SettingsPage() {
</div>
</SectionCard>
<SectionCard
id="app"
title="Приложение"
description="Установка приложения на устройство и базовый PWA-статус."
>
<div className="grid gap-4 md:grid-cols-2">
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex items-start gap-3">
<span
className={`app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl ${
isInstalled ? "app-badge-accent" : ""
}`}
>
<Download size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">Статус установки</p>
<p className="app-text-muted mt-1 text-sm">
{isInstalled
? "Приложение уже установлено на это устройство."
: canInstall
? "Приложение можно установить через системный prompt браузера."
: "Установка через браузер сейчас недоступна."}
</p>
</div>
</div>
</div>
<div className="app-surface-muted rounded-2xl p-4">
<div className="flex items-start gap-3">
<span className="app-badge flex h-10 w-10 shrink-0 items-center justify-center rounded-xl">
<Smartphone size={18} />
</span>
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">PWA-компоненты</p>
<p className="app-text-muted mt-1 text-sm">
Service Worker: {isRegistrationReady ? "зарегистрирован" : "не зарегистрирован"}.
</p>
<div className="mt-3">
<button
type="button"
onClick={() => void install()}
disabled={!canInstall}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
>
<Download size={16} />
{isInstalled ? "Установлено" : "Установить приложение"}
</button>
</div>
</div>
</div>
</div>
</div>
</SectionCard>
<SectionCard
id="contacts"
title="Контакты"

View File

@ -1,6 +1,6 @@
"use client";
import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react";
import { Building2, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
@ -13,6 +13,7 @@ import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
import { useCalendarModals } from "@/hooks/useCalendarModals";
import { CalendarModals } from "@/components/layout/CalendarModals";
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
import { usePwa } from "@/contexts/PwaContext";
type AppShellProps = {
children: ReactNode;
@ -51,6 +52,7 @@ const navItems = [
function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
const { canInstall, install } = usePwa();
const [searchQuery, setSearchQuery] = useState("");
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
@ -147,6 +149,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
</button>
<Link href="/" className="flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="Логотип"
@ -200,6 +203,7 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
onClick={() => setUserMenuOpen((v) => !v)}
>
{user?.avatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
@ -216,6 +220,18 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
className="app-action-ghost w-full px-4 py-2 text-left text-sm transition"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
{canInstall ? (
<button
className="app-action-ghost flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition"
onClick={() => {
setUserMenuOpen(false);
void install();
}}
>
<Download size={16} />
Установить
</button>
) : null}
<div className="app-divider my-1 border-t" />
<button
className="app-action-danger w-full px-4 py-2 text-left text-sm transition"

View File

@ -42,12 +42,26 @@ const localizer = dateFnsLocalizer({
locales,
});
type CalendarDisplayEvent = CalendarEventData & {
type CalendarDisplayEvent = Omit<CalendarEventData, "start" | "end"> & {
start: Date;
end: Date;
allDay: boolean;
};
function toEventDraft(event: CalendarEventData | CalendarDisplayEvent): CalendarEventDraft {
const normalizedId =
typeof event.id === "number"
? event.id
: typeof event.id === "string" && Number.isFinite(Number(event.id))
? Number(event.id)
: undefined;
return {
...event,
id: normalizedId,
};
}
// Кастомные сообщения на русском
const messages = {
allDay: "Весь день",
@ -349,7 +363,7 @@ export function BigCalendar() {
}
} else {
// Обычное событие - открываем детали
setViewingEvent(calEvent);
setViewingEvent(toEventDraft(calEvent));
setShowEventDetailsModal(true);
}
}, []);
@ -462,7 +476,7 @@ export function BigCalendar() {
console.error("Ошибка загрузки события:", err);
}
} else {
setViewingEvent(event);
setViewingEvent(toEventDraft(event));
setShowEventDetailsModal(true);
}
}, []);

View File

@ -340,11 +340,11 @@ export default function CalendarParticipantsModal({
<p className="app-text-muted py-4 text-center text-sm">Нет участников</p>
) : (
participants.map((participant) => {
const user = participant.user || {};
const firstName = user.first_name || 'N';
const lastName = user.last_name || 'A';
const username = typeof user.username === 'string' ? user.username.trim() : '';
const secondaryLabel = user.email || (username ? `@${username}` : '');
const user = participant.user;
const firstName = user?.first_name || 'N';
const lastName = user?.last_name || 'A';
const username = typeof user?.username === 'string' ? user.username.trim() : '';
const secondaryLabel = user?.email || (username ? `@${username}` : '');
return (
<div
@ -352,7 +352,7 @@ export default function CalendarParticipantsModal({
className="app-surface-elevated flex items-center justify-between rounded-lg px-4 py-3 transition hover:bg-[var(--surface-secondary)]"
>
<div className="flex items-center gap-3">
{user.avatar ? (
{user?.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveMediaUrl(user.avatar)}

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Check, Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { Bell, BellOff, Eye, EyeOff, Loader2 } from "lucide-react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
@ -9,7 +9,7 @@ interface Calendar {
id: number;
name: string;
title?: string;
color: string;
color?: string;
is_subscribed?: boolean;
subscriber_count?: number;
visibility?: string;
@ -191,7 +191,7 @@ export function CalendarSubscriptionsModal({ isOpen, onClose, onUpdate }: Props)
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded"
style={{ backgroundColor: cal.color }}
style={{ backgroundColor: cal.color || "var(--accent-primary)" }}
/>
<div>
<p className="font-medium text-[var(--foreground)]">{cal.name || cal.title}</p>

View File

@ -4,6 +4,7 @@ import { UserProvider } from "@/contexts/UserContext";
import { CalendarProvider } from "@/contexts/CalendarContext";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
import { MobileNavPlacementProvider } from "@/contexts/MobileNavPlacementContext";
import { PwaProvider } from "@/contexts/PwaContext";
import { ThemeProvider, useTheme } from "@/contexts/ThemeContext";
import { ReactNode } from "react";
import { Toaster } from "sonner";
@ -21,8 +22,10 @@ export function Providers({ children }: { children: ReactNode }) {
<UserProvider>
<NotificationsProvider>
<CalendarProvider>
<AppToaster />
{children}
<PwaProvider>
<AppToaster />
{children}
</PwaProvider>
</CalendarProvider>
</NotificationsProvider>
</UserProvider>

View File

@ -11,6 +11,7 @@ import {
formatDateKey,
type CalendarEvent,
type CalendarEventDraft,
toCalendarEventDraft,
} from "@/services/calendarService";
import { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
import {
@ -153,12 +154,12 @@ export const CalendarCard = memo(function CalendarCard({
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await calendarService.loadFullEvent(event.event_id, event.start || undefined, event.end || undefined);
onOpenEventModal(fullEvent);
onOpenEventModal(toCalendarEventDraft(fullEvent));
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
} else {
onOpenEventModal(event);
onOpenEventModal(toCalendarEventDraft(event));
}
}, [onOpenEventModal]);
@ -622,7 +623,7 @@ export const CalendarCard = memo(function CalendarCard({
<div className="flex items-center gap-1">
<div
className="h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: resolveEventColor(event.color_event || event.color) }}
style={{ backgroundColor: resolveEventColor(event.color_event) }}
/>
<p className="truncate text-xs font-medium text-[var(--foreground)]">{event.title}</p>
{(event.is_recurring || event.rule) && (

View File

@ -0,0 +1,95 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import {
type BeforeInstallPromptEvent,
isStandaloneDisplayMode,
registerAppServiceWorker,
} from "@/lib/pwa";
type InstallResult = "accepted" | "dismissed" | "unavailable";
type PwaContextValue = {
canInstall: boolean;
isInstalled: boolean;
isRegistrationReady: boolean;
install: () => Promise<InstallResult>;
};
const PwaContext = createContext<PwaContextValue | undefined>(undefined);
export function PwaProvider({ children }: { children: ReactNode }) {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(() => isStandaloneDisplayMode());
const [isRegistrationReady, setIsRegistrationReady] = useState(false);
useEffect(() => {
void registerAppServiceWorker()
.then((registration) => {
setIsRegistrationReady(Boolean(registration));
})
.catch((error) => {
console.error("[PWA] Service Worker registration failed:", error);
setIsRegistrationReady(false);
});
}, []);
useEffect(() => {
const handleBeforeInstallPrompt = (event: Event) => {
const installEvent = event as BeforeInstallPromptEvent;
installEvent.preventDefault();
setDeferredPrompt(installEvent);
};
const handleAppInstalled = () => {
setDeferredPrompt(null);
setIsInstalled(true);
};
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
};
}, []);
const install = useCallback(async (): Promise<InstallResult> => {
if (!deferredPrompt) {
return "unavailable";
}
await deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
if (choice.outcome === "accepted") {
setIsInstalled(true);
setDeferredPrompt(null);
return "accepted";
}
return "dismissed";
}, [deferredPrompt]);
const value = useMemo<PwaContextValue>(
() => ({
canInstall: !isInstalled && deferredPrompt !== null,
isInstalled,
isRegistrationReady,
install,
}),
[deferredPrompt, install, isInstalled, isRegistrationReady],
);
return <PwaContext.Provider value={value}>{children}</PwaContext.Provider>;
}
export function usePwa() {
const context = useContext(PwaContext);
if (!context) {
throw new Error("usePwa must be used within a PwaProvider");
}
return context;
}

View File

@ -4,7 +4,11 @@ import { useState, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { type CalendarParticipantsTarget } from "@/lib/calendar/ui";
import { DEFAULT_EVENT_COLOR } from "@/lib/calendar-event-colors";
import type { CalendarEvent, CalendarEventDraft } from "@/services/calendarService";
import {
toCalendarEventDraft,
type CalendarEvent,
type CalendarEventDraft,
} from "@/services/calendarService";
export interface CalendarModalsState {
showCalendarModal: boolean;
@ -16,7 +20,7 @@ export interface CalendarModalsState {
editingEvent: CalendarEventDraft | null;
participantsCalendar: CalendarParticipantsTarget | null;
selectedDateForModal: Date | null;
viewingEvent: CalendarEvent | null;
viewingEvent: CalendarEventDraft | null;
sidebarEvents: CalendarEvent[];
currentSelectedCalendarId: number | null;
eventsRefreshTrigger: number;
@ -33,7 +37,7 @@ export function useCalendarModals() {
const [editingEvent, setEditingEvent] = useState<CalendarEventDraft | null>(null);
const [participantsCalendar, setParticipantsCalendar] = useState<CalendarParticipantsTarget | null>(null);
const [selectedDateForModal, setSelectedDateForModal] = useState<Date | null>(null);
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
const [viewingEvent, setViewingEvent] = useState<CalendarEventDraft | null>(null);
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(null);
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
@ -60,7 +64,7 @@ export function useCalendarModals() {
setSelectedDateForModal(date);
setShowDayEventsModal(true);
} else if (event.id) {
setViewingEvent(event as CalendarEvent);
setViewingEvent(event);
setShowEventDetailsModal(true);
} else {
setEditingEvent(event);
@ -106,17 +110,17 @@ export function useCalendarModals() {
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
setViewingEvent({
setViewingEvent(toCalendarEventDraft({
...fullEvent,
start: event.start,
end: event.end,
});
}));
setShowEventDetailsModal(true);
} catch (err) {
console.error("Ошибка загрузки события:", err);
}
} else {
setViewingEvent(event);
setViewingEvent(toCalendarEventDraft(event));
setShowEventDetailsModal(true);
}
}, []);

View File

@ -2,6 +2,8 @@
* Web Push утилиты для управления подписками
*/
import { registerAppServiceWorker } from "@/lib/pwa";
/**
* Конвертирует base64 строку в Uint8Array для applicationServerKey
*/
@ -68,27 +70,12 @@ export async function requestNotificationPermission(): Promise<NotificationPermi
* Регистрирует Service Worker
*/
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration> {
if (!('serviceWorker' in navigator)) {
const registration = await registerAppServiceWorker();
if (!registration) {
throw new Error('Service Worker не поддерживается');
}
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/'
});
console.log('[PushNotifications] Service Worker зарегистрирован:', registration.scope);
// Ждем активации
if (registration.installing) {
await new Promise<void>((resolve) => {
registration.installing!.addEventListener('statechange', (e) => {
if ((e.target as ServiceWorker).state === 'activated') {
resolve();
}
});
});
}
return registration;
}

49
frontend/src/lib/pwa.ts Normal file
View File

@ -0,0 +1,49 @@
"use client";
export interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
prompt: () => Promise<void>;
}
export function isStandaloneDisplayMode(): boolean {
if (typeof window === "undefined") {
return false;
}
const standaloneMatch = window.matchMedia?.("(display-mode: standalone)").matches;
const navigatorStandalone =
typeof navigator !== "undefined" &&
"standalone" in navigator &&
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
return Boolean(standaloneMatch || navigatorStandalone);
}
export async function registerAppServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (typeof window === "undefined" || !("serviceWorker" in navigator)) {
return null;
}
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
if (existingRegistration) {
await existingRegistration.update().catch(() => undefined);
return existingRegistration;
}
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
if (registration.installing) {
await new Promise<void>((resolve) => {
registration.installing?.addEventListener("statechange", (event) => {
if ((event.target as ServiceWorker).state === "activated") {
resolve();
}
});
});
}
return registration;
}

View File

@ -27,6 +27,25 @@ export type CalendarEventDraft = Record<string, unknown> & {
can_delete?: boolean;
};
export function toCalendarEventDraft(
event: CalendarEvent | CalendarEventDetails,
): CalendarEventDraft {
return {
...event,
id: typeof event.id === "number" ? event.id : undefined,
start: event.start ?? null,
end: event.end ?? null,
calendar:
typeof event.calendar === "number" ? event.calendar : event.calendar ?? null,
color_event: event.color_event ?? null,
rule: event.rule ?? null,
event_id: "event_id" in event ? event.event_id : undefined,
is_recurring: "is_recurring" in event ? event.is_recurring : false,
can_edit: event.can_edit,
can_delete: event.can_delete,
};
}
interface EventsCache {
key: string;
events: CalendarEvent[];