From a845f51a35467da90f82af9abfbfed56930be6e3 Mon Sep 17 00:00:00 2001 From: Ken Yasue Date: Thu, 25 Jun 2026 12:01:01 +0200 Subject: [PATCH] feat(pwa): installable PWA + mobile responsive chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PWA対応とモバイルUX改善。 - app/manifest.ts: Nextファイルベースでマニフェスト生成(name/short_name/display:standalone/icons 192+512 any+maskable + svg) - public/icon.svg + 生成PNG(192/512): scripts/generate-icons.mjs で sharp を用いてSVGをラスタライズ - public/sw.js: アプリシェルのプリキャッシュ、ナビゲーションはネットワーク優先(オフラインはキャッシュ/'/'にフォールバック)、静的アセットはキャッシュ優先。API/非GET/クロスオリジンはキャッシュせず、res.ok&&basic でエラー/リダイレクト結果を弾く - components/pwa/ServiceWorkerRegister: 本番のみ /sw.js を登録(開発HMRとの衝突回避) - app/layout.tsx: export const viewport(device-width, cover, themeColor) + metadata.icons/appleWebApp - middleware: sw.js/manifest/icon を認証ガードから除外(ログイン前でも取得可能) - ProjectNav: 横スクロールStrip(折り返さない)、Header: モバイルでダッシュボードテキストリンクを非表示 --- app/layout.tsx | 21 +++++- app/manifest.ts | 51 ++++++++++++++ components/layout/Header.tsx | 4 +- components/layout/ProjectNav.tsx | 5 +- components/pwa/ServiceWorkerRegister.tsx | 27 ++++++++ eslint.config.js | 2 + middleware.ts | 4 +- public/icon-192.png | Bin 0 -> 3840 bytes public/icon-512.png | Bin 0 -> 14645 bytes public/icon.svg | 5 ++ public/sw.js | 82 +++++++++++++++++++++++ scripts/generate-icons.mjs | 26 +++++++ tests/e2e/pwa.spec.ts | 75 +++++++++++++++++++++ 13 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 app/manifest.ts create mode 100644 components/pwa/ServiceWorkerRegister.tsx create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon.svg create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.mjs create mode 100644 tests/e2e/pwa.spec.ts diff --git a/app/layout.tsx b/app/layout.tsx index f836cd7..fd699d1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,12 +1,30 @@ -import type { Metadata } from 'next'; +import type { Metadata, Viewport } from 'next'; import { cookies } from 'next/headers'; import './globals.css'; import { I18nProvider } from '@/lib/i18n/I18nProvider'; +import { ServiceWorkerRegister } from '@/components/pwa/ServiceWorkerRegister'; import type { Locale, Theme } from '@/lib/types'; export const metadata: Metadata = { title: 'Groupware', description: 'Project-based team collaboration tool', + applicationName: 'Groupware', + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: 'Groupware', + }, + icons: { + icon: '/icon.svg', + apple: '/icon-192.png', + }, +}; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', + themeColor: '#2563eb', }; function resolveTheme(v: string | undefined): Theme { @@ -36,6 +54,7 @@ export default async function RootLayout({ {children} + ); diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..4944d6e --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,51 @@ +import type { MetadataRoute } from 'next'; + +/** + * PWA マニフェスト。Next.js が /manifest.webmanifest を生成し、 + * を自動で に出力する。 + */ +export default function manifest(): MetadataRoute.Manifest { + return { + name: 'Groupware', + short_name: 'Groupware', + description: 'Project-based team collaboration tool', + start_url: '/', + scope: '/', + display: 'standalone', + orientation: 'any', + background_color: '#111827', + theme_color: '#2563eb', + lang: 'en', + icons: [ + { + src: '/icon-192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'any', + }, + { + src: '/icon-192.png', + sizes: '192x192', + type: 'image/png', + purpose: 'maskable', + }, + { + src: '/icon-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'any', + }, + { + src: '/icon-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + { + src: '/icon.svg', + sizes: 'any', + type: 'image/svg+xml', + }, + ], + }; +} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index ae4d868..e3de4e9 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -24,10 +24,10 @@ export function Header({ user }: { user: PublicUser }) { > {t('app.name')} -