feat: add installable pwa baseline
This commit is contained in:
parent
7ccf69e8ef
commit
2e4975a901
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -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.
|
||||
|
||||
BIN
frontend/public/apple-touch-icon.png
Normal file
BIN
frontend/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
BIN
frontend/public/icon-192.png
Normal file
BIN
frontend/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/public/icon-512-maskable.png
Normal file
BIN
frontend/public/icon-512-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/icon-512.png
Normal file
BIN
frontend/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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({
|
||||
|
||||
32
frontend/src/app/manifest.ts
Normal file
32
frontend/src/app/manifest.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@ -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="Контакты"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) && (
|
||||
|
||||
95
frontend/src/contexts/PwaContext.tsx
Normal file
95
frontend/src/contexts/PwaContext.tsx
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -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
49
frontend/src/lib/pwa.ts
Normal 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;
|
||||
}
|
||||
@ -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[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user