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

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();
})()
);
});