Skip to content

Commit 1c26edb

Browse files
authored
Merge pull request #48 from windyakin/windyakin_20260113_ac27096dd6
もろもろを修正する
2 parents a70092f + 6f73b33 commit 1c26edb

13 files changed

Lines changed: 388 additions & 25 deletions

backend/src/services/database.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
1-
import { PrismaClient } from "@prisma/client";
1+
import { Prisma, PrismaClient } from "@prisma/client";
22
import type { ParsedPromptData } from "../types/prompt.js";
33

44
export const prisma = new PrismaClient();
55

6+
/**
7+
* Find or create a tag, handling concurrent creation race conditions.
8+
* Uses upsert with fallback to findUnique on unique constraint violation.
9+
*/
10+
async function findOrCreateTag(
11+
tx: Prisma.TransactionClient,
12+
name: string,
13+
category: string | null
14+
) {
15+
try {
16+
return await tx.tag.upsert({
17+
where: { name },
18+
update: {},
19+
create: { name, category },
20+
});
21+
} catch (error) {
22+
// P2002: Unique constraint violation - another transaction created the tag
23+
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
24+
const existingTag = await tx.tag.findUnique({ where: { name } });
25+
if (existingTag) {
26+
return existingTag;
27+
}
28+
// If still not found, rethrow
29+
throw error;
30+
}
31+
throw error;
32+
}
33+
}
34+
635
export interface CreateImageInput {
736
filename: string;
837
s3Key: string;
@@ -68,15 +97,8 @@ export async function createImageWithMetadata(input: CreateImageInput): Promise<
6897
const colonIndex = weightedTag.name.indexOf(":");
6998
const category = colonIndex > 0 ? weightedTag.name.slice(0, colonIndex) : null;
7099

71-
// Use upsert to handle concurrent uploads with same tags
72-
const tag = await tx.tag.upsert({
73-
where: { name: weightedTag.name },
74-
update: {}, // No update needed if tag already exists
75-
create: {
76-
name: weightedTag.name,
77-
category,
78-
},
79-
});
100+
// Use findOrCreateTag to handle concurrent uploads with same tags
101+
const tag = await findOrCreateTag(tx, weightedTag.name, category);
80102

81103
await tx.imageTag.create({
82104
data: {

frontend/generate-icons.mjs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,38 @@ const svg = readFileSync('./public/soeji_r.svg');
55

66
async function generateIcons() {
77
try {
8+
// Standard PWA icons
89
await sharp(svg)
910
.resize(192, 192)
1011
.png()
1112
.toFile('./public/pwa-192x192.png');
12-
1313
console.log('Generated pwa-192x192.png');
1414

1515
await sharp(svg)
1616
.resize(512, 512)
1717
.png()
1818
.toFile('./public/pwa-512x512.png');
19-
2019
console.log('Generated pwa-512x512.png');
2120

21+
// iOS用 Apple Touch Icons
22+
await sharp(svg)
23+
.resize(180, 180)
24+
.png()
25+
.toFile('./public/apple-touch-icon-180x180.png');
26+
console.log('Generated apple-touch-icon-180x180.png');
27+
28+
await sharp(svg)
29+
.resize(152, 152)
30+
.png()
31+
.toFile('./public/apple-touch-icon-152x152.png');
32+
console.log('Generated apple-touch-icon-152x152.png');
33+
34+
await sharp(svg)
35+
.resize(120, 120)
36+
.png()
37+
.toFile('./public/apple-touch-icon-120x120.png');
38+
console.log('Generated apple-touch-icon-120x120.png');
39+
2240
console.log('All PWA icons generated successfully!');
2341
} catch (error) {
2442
console.error('Error generating icons:', error);

frontend/index.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
<link rel="icon" type="image/svg+xml" href="/soeji.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
77
<meta name="description" content="画像の検索とタグ付けができる画像管理システム" />
8-
<meta name="theme-color" content="#ffffff" />
9-
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
8+
<meta name="theme-color" content="#10B981" />
9+
<!-- iOS PWA設定 -->
10+
<meta name="apple-mobile-web-app-capable" content="yes" />
11+
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
12+
<meta name="apple-mobile-web-app-title" content="Soeji" />
13+
<!-- Apple Touch Icons -->
14+
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
15+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180x180.png" />
16+
<link rel="apple-touch-icon" sizes="152x152" href="/apple-touch-icon-152x152.png" />
17+
<link rel="apple-touch-icon" sizes="120x120" href="/apple-touch-icon-120x120.png" />
1018
<title>Soeji</title>
1119
</head>
1220
<body>
2.91 KB
Loading
3.74 KB
Loading
4.52 KB
Loading
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<script setup lang="ts">
2+
import Tag from 'primevue/tag'
3+
import Dialog from 'primevue/dialog'
4+
import Button from 'primevue/button'
5+
import { usePwaInstall } from '../composables/usePwaInstall'
6+
import SettingsCard from './SettingsCard.vue'
7+
8+
const {
9+
canInstall,
10+
isInstalled,
11+
isIos,
12+
isStandalone,
13+
showIosInstallGuide,
14+
promptInstall,
15+
dismissIosGuide
16+
} = usePwaInstall()
17+
18+
async function handleInstall() {
19+
await promptInstall()
20+
}
21+
</script>
22+
23+
<template>
24+
<SettingsCard header="App">
25+
<!-- 既にインストール済み(スタンドアロンモード) -->
26+
<div v-if="isStandalone" class="settings-item">
27+
<div class="item-content">
28+
<div class="item-left">
29+
<i class="pi pi-check-circle item-icon" style="color: var(--p-green-500);"></i>
30+
<span class="item-label">Installed as App</span>
31+
</div>
32+
<Tag severity="success" value="Active" />
33+
</div>
34+
</div>
35+
36+
<!-- インストール可能(非iOS) -->
37+
<button
38+
v-else-if="canInstall && !isIos"
39+
class="settings-item clickable"
40+
@click="handleInstall"
41+
>
42+
<div class="item-content">
43+
<div class="item-left">
44+
<i class="pi pi-download item-icon"></i>
45+
<span class="item-label">Install App</span>
46+
</div>
47+
<div class="item-right">
48+
<Tag severity="info" value="Available" />
49+
<i class="pi pi-chevron-right item-chevron"></i>
50+
</div>
51+
</div>
52+
</button>
53+
54+
<!-- iOS用インストールガイド -->
55+
<button
56+
v-else-if="canInstall && isIos"
57+
class="settings-item clickable"
58+
@click="handleInstall"
59+
>
60+
<div class="item-content">
61+
<div class="item-left">
62+
<i class="pi pi-apple item-icon"></i>
63+
<span class="item-label">Add to Home Screen</span>
64+
</div>
65+
<div class="item-right">
66+
<i class="pi pi-chevron-right item-chevron"></i>
67+
</div>
68+
</div>
69+
</button>
70+
71+
<!-- インストール不可(ブラウザが非対応など) -->
72+
<div v-else-if="!isInstalled && !canInstall" class="settings-item">
73+
<div class="item-content">
74+
<div class="item-left">
75+
<i class="pi pi-info-circle item-icon" style="color: var(--p-text-muted-color);"></i>
76+
<div class="item-text">
77+
<span class="item-label">Install App</span>
78+
<span class="item-description">Not supported in this browser</span>
79+
</div>
80+
</div>
81+
</div>
82+
</div>
83+
84+
<!-- iOS インストールガイドダイアログ -->
85+
<Dialog
86+
v-model:visible="showIosInstallGuide"
87+
modal
88+
header="Add to Home Screen"
89+
:style="{ width: '90vw', maxWidth: '400px' }"
90+
>
91+
<div class="ios-guide">
92+
<ol class="ios-guide-steps">
93+
<li>
94+
<div class="step-content">
95+
<span>Tap the</span>
96+
<i class="pi pi-ellipsis-h menu-icon"></i>
97+
<span>menu button at bottom right</span>
98+
</div>
99+
</li>
100+
<li>
101+
<div class="step-content">
102+
<span>Select "Share" then tap "More"</span>
103+
</div>
104+
</li>
105+
<li>
106+
<div class="step-content">
107+
<span>Select "Add to Home Screen"</span>
108+
</div>
109+
</li>
110+
<li>
111+
<div class="step-content">
112+
<span>Tap "Add" to complete</span>
113+
</div>
114+
</li>
115+
</ol>
116+
</div>
117+
<template #footer>
118+
<Button label="Close" @click="dismissIosGuide" />
119+
</template>
120+
</Dialog>
121+
</SettingsCard>
122+
</template>
123+
124+
<style scoped>
125+
.ios-guide {
126+
padding: 0.5rem 0;
127+
}
128+
129+
.ios-guide-steps {
130+
margin: 0;
131+
padding: 0 0 0 1.5rem;
132+
display: flex;
133+
flex-direction: column;
134+
gap: 1rem;
135+
}
136+
137+
.ios-guide-steps li {
138+
font-size: 0.9375rem;
139+
line-height: 1.5;
140+
}
141+
142+
.step-content {
143+
display: flex;
144+
align-items: center;
145+
gap: 0.375rem;
146+
flex-wrap: wrap;
147+
}
148+
149+
.menu-icon {
150+
display: inline-flex;
151+
align-items: center;
152+
justify-content: center;
153+
width: 1.75rem;
154+
height: 1.75rem;
155+
background: var(--p-surface-200);
156+
color: var(--p-text-color);
157+
border-radius: 0.375rem;
158+
font-size: 0.875rem;
159+
}
160+
</style>

frontend/src/composables/useAuth.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import type {
1010

1111
const API_BASE = import.meta.env.VITE_API_BASE || "";
1212

13-
// Storage key for user info only (tokens are in httpOnly cookies)
13+
// Storage keys (tokens are in httpOnly cookies, only metadata stored locally)
1414
const STORAGE_KEY_USER = "soeji-auth-user";
15+
const STORAGE_KEY_TOKEN_EXPIRES = "soeji-auth-token-expires";
1516

1617
// Token refresh check interval (every 30 seconds)
1718
const TOKEN_CHECK_INTERVAL_MS = 30 * 1000;
@@ -42,11 +43,16 @@ async function checkAndRefreshToken(): Promise<void> {
4243
return;
4344
}
4445

45-
if (accessTokenExpiresAt) {
46-
const remainingMs = accessTokenExpiresAt.getTime() - Date.now();
47-
if (remainingMs < TOKEN_REFRESH_THRESHOLD_MS) {
48-
await doRefreshAccessToken();
49-
}
46+
// If accessTokenExpiresAt is not available (e.g., after process restart),
47+
// attempt refresh to ensure we have a valid token
48+
if (!accessTokenExpiresAt) {
49+
await doRefreshAccessToken();
50+
return;
51+
}
52+
53+
const remainingMs = accessTokenExpiresAt.getTime() - Date.now();
54+
if (remainingMs < TOKEN_REFRESH_THRESHOLD_MS) {
55+
await doRefreshAccessToken();
5056
}
5157
}
5258

@@ -98,6 +104,8 @@ async function doRefreshAccessToken(): Promise<boolean> {
98104

99105
const data: RefreshResponse = await response.json();
100106
accessTokenExpiresAt = new Date(data.accessTokenExpiresAt);
107+
// Persist to localStorage for recovery after process restart
108+
localStorage.setItem(STORAGE_KEY_TOKEN_EXPIRES, data.accessTokenExpiresAt);
101109

102110
return true;
103111
} catch (error) {
@@ -109,13 +117,19 @@ async function doRefreshAccessToken(): Promise<boolean> {
109117
// Load stored user on initialization
110118
function loadStoredUser(): void {
111119
const storedUser = localStorage.getItem(STORAGE_KEY_USER);
120+
const storedExpires = localStorage.getItem(STORAGE_KEY_TOKEN_EXPIRES);
112121

113122
if (storedUser) {
114123
try {
115124
const user = JSON.parse(storedUser);
116125
currentUser.value = user;
117126
isAuthenticated.value = true;
118127
mustChangePassword.value = user.mustChangePassword || false;
128+
129+
// Restore token expiration time if available
130+
if (storedExpires) {
131+
accessTokenExpiresAt = new Date(storedExpires);
132+
}
119133
} catch {
120134
clearAuthState();
121135
}
@@ -125,6 +139,7 @@ function loadStoredUser(): void {
125139
function clearAuthState(): void {
126140
stopRefreshCheckTimer();
127141
localStorage.removeItem(STORAGE_KEY_USER);
142+
localStorage.removeItem(STORAGE_KEY_TOKEN_EXPIRES);
128143
currentUser.value = null;
129144
isAuthenticated.value = false;
130145
mustChangePassword.value = false;
@@ -134,6 +149,7 @@ function clearAuthState(): void {
134149

135150
function setAuthState(user: AuthUser, expiresAt: Date): void {
136151
localStorage.setItem(STORAGE_KEY_USER, JSON.stringify(user));
152+
localStorage.setItem(STORAGE_KEY_TOKEN_EXPIRES, expiresAt.toISOString());
137153
currentUser.value = user;
138154
isAuthenticated.value = true;
139155
mustChangePassword.value = user.mustChangePassword || false;

0 commit comments

Comments
 (0)