feat(pwa): installable PWA + mobile responsive chrome

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: モバイルでダッシュボードテキストリンクを非表示
This commit is contained in:
Ken Yasue
2026-06-25 12:01:01 +02:00
parent 9d7a23ea94
commit a845f51a35
13 changed files with 296 additions and 6 deletions

View File

@ -1,12 +1,30 @@
import type { Metadata } from 'next'; import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import './globals.css'; import './globals.css';
import { I18nProvider } from '@/lib/i18n/I18nProvider'; import { I18nProvider } from '@/lib/i18n/I18nProvider';
import { ServiceWorkerRegister } from '@/components/pwa/ServiceWorkerRegister';
import type { Locale, Theme } from '@/lib/types'; import type { Locale, Theme } from '@/lib/types';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Groupware', title: 'Groupware',
description: 'Project-based team collaboration tool', 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 { function resolveTheme(v: string | undefined): Theme {
@ -36,6 +54,7 @@ export default async function RootLayout({
<I18nProvider initialLocale={locale} initialTheme={theme}> <I18nProvider initialLocale={locale} initialTheme={theme}>
{children} {children}
</I18nProvider> </I18nProvider>
<ServiceWorkerRegister />
</body> </body>
</html> </html>
); );

51
app/manifest.ts Normal file
View File

@ -0,0 +1,51 @@
import type { MetadataRoute } from 'next';
/**
* PWA マニフェスト。Next.js が /manifest.webmanifest を生成し、
* <link rel="manifest"> を自動で <head> に出力する。
*/
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',
},
],
};
}

View File

@ -24,10 +24,10 @@ export function Header({ user }: { user: PublicUser }) {
> >
{t('app.name')} {t('app.name')}
</a> </a>
<nav className="flex items-center gap-4 text-sm"> <nav className="flex items-center gap-3 text-sm sm:gap-4">
<a <a
href="/dashboard" href="/dashboard"
className="text-gray-600 hover:underline dark:text-gray-300" className="hidden text-gray-600 hover:underline sm:inline dark:text-gray-300"
> >
{t('nav.dashboard')} {t('nav.dashboard')}
</a> </a>

View File

@ -46,7 +46,10 @@ export function ProjectNav({
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<nav className="flex gap-4 border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"> <nav
data-testid="project-nav"
className="flex gap-4 overflow-x-auto whitespace-nowrap border-b bg-white px-6 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const href = `/projects/${projectId}${item.href}`; const href = `/projects/${projectId}${item.href}`;
return ( return (

View File

@ -0,0 +1,27 @@
'use client';
import { useEffect } from 'react';
/**
* Service Worker を登録する(本番環境のみ)。
* 開発環境では Next.js の HMR とSWキャッシュが衝突するため登録しない。
* 登録失敗はPWAがプログレッシブ拡張であるため無視する。
*/
export function ServiceWorkerRegister() {
useEffect(() => {
if (process.env.NODE_ENV !== 'production') return;
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
const register = () => {
navigator.serviceWorker.register('/sw.js').catch(() => undefined);
};
if (document.readyState === 'complete') {
register();
} else {
window.addEventListener('load', register, { once: true });
}
}, []);
return null;
}

View File

@ -28,6 +28,8 @@ export default tseslint.config(
'playwright-report/**', 'playwright-report/**',
'test-results/**', 'test-results/**',
'next-env.d.ts', 'next-env.d.ts',
'scripts/**/*.mjs',
'public/**',
], ],
} }
); );

View File

@ -19,7 +19,7 @@ export function middleware(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
// /login, /api, _next 静的 assets, favicon を除外 // /login, /api, _next 静的 assets, favicon, PWA静的リソース(manifest/SW/icon) を除外
'/((?!login|api|_next/static|_next/image|favicon.ico).*)', '/((?!login|api|_next/static|_next/image|favicon.ico|sw\\.js|manifest\\.webmanifest|icon\\.svg|icon-192\\.png|icon-512\\.png).*)',
], ],
}; };

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

5
public/icon.svg Normal file
View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512" role="img" aria-label="Groupware">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<circle cx="256" cy="196" r="84" fill="#ffffff"/>
<path d="M128 432c0-70.7 57.3-128 128-128s128 57.3 128 128v24H128z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

82
public/sw.js Normal file
View File

@ -0,0 +1,82 @@
// Groupware service worker: app-shell precache + network-first navigations +
// cache-first static assets for offline support and installability.
const CACHE = 'ogw-v1';
const PRECACHE_URLS = [
'/',
'/manifest.webmanifest',
'/icon.svg',
'/icon-192.png',
'/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(CACHE);
// 一つでも欠けていても全体が失敗しないよう個別に追加
await Promise.allSettled(PRECACHE_URLS.map((u) => cache.add(u)));
await self.skipWaiting();
})()
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
);
await self.clients.claim();
})()
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// クロスオリジンとAPIはキャッシュしない
if (url.origin !== self.location.origin || url.pathname.startsWith('/api')) {
return;
}
// ナビゲーション: ネットワーク優先(オフライン時はキャッシュ/"/"にフォールバック)
if (req.mode === 'navigate') {
event.respondWith(
(async () => {
try {
const res = await fetch(req);
// エラーや認証リダイレクトの結果をキャッシュしない
if (res.ok && res.type === 'basic') {
const cache = await caches.open(CACHE);
cache.put(req, res.clone());
}
return res;
} catch {
const cached = await caches.match(req);
return cached || (await caches.match('/')) || Response.error();
}
})()
);
return;
}
// 静的アセット: キャッシュ優先(stale-while-revalidate)
event.respondWith(
(async () => {
const cached = await caches.match(req);
const network = fetch(req)
.then((res) => {
if (res && res.status === 200 && res.type === 'basic') {
const cache = caches.open(CACHE);
cache.then((c) => c.put(req, res.clone()));
}
return res;
})
.catch(() => undefined);
return cached || (await network) || Response.error();
})()
);
});

View File

@ -0,0 +1,26 @@
// One-off: rasterize public/icon.svg into 512 and 192 PNGs (any/maskable).
// Uses sharp (already a transitive dependency in this project). Run: node scripts/generate-icons.mjs
import sharp from 'sharp';
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const SVG = path.join(ROOT, 'public/icon.svg');
async function main() {
const svgBuf = fs.readFileSync(SVG);
const sizes = [512, 192];
for (const size of sizes) {
const out = path.join(ROOT, `public/icon-${size}.png`);
await sharp(svgBuf, { density: 384 })
.resize(size, size, { fit: 'contain' })
.png()
.toFile(out);
console.log(`wrote ${out} (${size}x${size})`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

75
tests/e2e/pwa.spec.ts Normal file
View File

@ -0,0 +1,75 @@
import { test, expect, type Page } from '@playwright/test';
function unique(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
}
async function setupOwner(page: Page): Promise<number> {
const email = unique('owner') + '@example.com';
await page.goto('/login');
await page.getByRole('button', { name: '新規登録はこちら' }).click();
await page.getByLabel('表示名').fill('Owner');
await page.getByLabel('メールアドレス').fill(email);
await page.getByLabel('パスワード').fill('password123');
await page.getByRole('button', { name: '登録する' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
await page.getByRole('button', { name: '新規プロジェクト' }).click();
await expect(page).toHaveURL(/\/projects\/\d+$/);
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
}
test.describe('pwa', () => {
test('manifest, service worker and icons are publicly served', async ({
request,
}) => {
const manifestRes = await request.get('/manifest.webmanifest');
expect(manifestRes.ok()).toBeTruthy();
const manifest = (await manifestRes.json()) as {
name: string;
short_name: string;
display: string;
start_url: string;
icons: { sizes: string; type: string; purpose?: string }[];
};
expect(manifest.name).toBe('Groupware');
expect(manifest.short_name).toBe('Groupware');
expect(manifest.display).toBe('standalone');
expect(manifest.start_url).toBe('/');
expect(manifest.icons.length).toBeGreaterThanOrEqual(2);
expect(manifest.icons.some((i) => i.sizes === '192x192')).toBeTruthy();
expect(manifest.icons.some((i) => i.sizes === '512x512')).toBeTruthy();
expect(manifest.icons.some((i) => i.purpose === 'maskable')).toBeTruthy();
const swRes = await request.get('/sw.js');
expect(swRes.ok()).toBeTruthy();
const swText = await swRes.text();
expect(swText).toContain('fetch');
expect(swText).toContain('install');
expect((await request.get('/icon-192.png')).ok()).toBeTruthy();
expect((await request.get('/icon-512.png')).ok()).toBeTruthy();
expect((await request.get('/icon.svg')).ok()).toBeTruthy();
});
test('project nav scrolls horizontally on mobile width (no wrap)', async ({
browser,
}) => {
const ctx = await browser.newContext({
storageState: 'tests/e2e/locale-ja.json',
viewport: { width: 375, height: 812 },
});
const page = await ctx.newPage();
await setupOwner(page);
const nav = page.getByTestId('project-nav');
await expect(nav).toBeVisible();
const scrollable = await nav.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
// ナビ項目が多数あるため、375px では横スクロールが発生する(折り返さない)
expect(scrollable.scrollWidth).toBeGreaterThan(scrollable.clientWidth);
await ctx.close();
});
});