TOMOCHIN4 commited on
Commit
f62d35e
·
1 Parent(s): edc211d

refactor: V1.x UI移植 - React+Tailwind SPA化

Browse files

- V1.8.1のReact+Tailwind UIをv2.0.0配信モードに完全移植
- SPA構成に変更(index.htmlのみで全画面管理)
- 不要なHTMLファイル削除(quiz.html, result.html, ranking.html)
- apiClient.js: GAS API用に書き換え
- components.js: 配信モード専用Reactコンポーネント
- V1.xと統一されたリッチUI(ベージュ/ブラウン配色、フォント、アニメーション)

Files changed (13) hide show
  1. css/style.css +65 -604
  2. index.html +57 -203
  3. js/api.js +0 -190
  4. js/apiClient.js +251 -0
  5. js/auth.js +0 -180
  6. js/components.js +875 -0
  7. js/config.js +7 -0
  8. js/icons.js +67 -0
  9. js/quiz.js +0 -145
  10. js/sessionManager.js +97 -0
  11. quiz.html +0 -344
  12. ranking.html +0 -175
  13. result.html +0 -151
css/style.css CHANGED
@@ -1,604 +1,65 @@
1
- /* ============================================
2
- 超天才クイズ v2.0.0 - Static Space CSS
3
- ============================================ */
4
-
5
- /* Reset & Base */
6
- * {
7
- margin: 0;
8
- padding: 0;
9
- box-sizing: border-box;
10
- }
11
-
12
- :root {
13
- --primary: #4F46E5;
14
- --primary-dark: #3730A3;
15
- --secondary: #10B981;
16
- --warning: #F59E0B;
17
- --danger: #EF4444;
18
- --success: #22C55E;
19
- --bg: #F3F4F6;
20
- --card-bg: #FFFFFF;
21
- --text: #1F2937;
22
- --text-light: #6B7280;
23
- --border: #E5E7EB;
24
- --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
25
- }
26
-
27
- body {
28
- font-family: 'Hiragino Kaku Gothic ProN', 'Noto Sans JP', sans-serif;
29
- background: var(--bg);
30
- color: var(--text);
31
- line-height: 1.6;
32
- min-height: 100vh;
33
- }
34
-
35
- .container {
36
- max-width: 480px;
37
- margin: 0 auto;
38
- padding: 16px;
39
- }
40
-
41
- /* Header */
42
- header {
43
- text-align: center;
44
- padding: 24px 0;
45
- }
46
-
47
- header h1 {
48
- font-size: 28px;
49
- color: var(--primary);
50
- margin-bottom: 4px;
51
- }
52
-
53
- header .subtitle {
54
- color: var(--text-light);
55
- font-size: 14px;
56
- }
57
-
58
- header .date {
59
- color: var(--text-light);
60
- font-size: 14px;
61
- }
62
-
63
- /* Card */
64
- .card {
65
- background: var(--card-bg);
66
- border-radius: 16px;
67
- padding: 24px;
68
- margin-bottom: 16px;
69
- box-shadow: var(--shadow);
70
- }
71
-
72
- .card h2 {
73
- font-size: 20px;
74
- margin-bottom: 16px;
75
- color: var(--primary);
76
- }
77
-
78
- .card h3 {
79
- font-size: 16px;
80
- margin-bottom: 12px;
81
- color: var(--text);
82
- }
83
-
84
- /* Buttons */
85
- button {
86
- cursor: pointer;
87
- border: none;
88
- border-radius: 8px;
89
- padding: 12px 24px;
90
- font-size: 16px;
91
- font-weight: 600;
92
- transition: all 0.2s;
93
- }
94
-
95
- .btn-primary {
96
- background: var(--primary);
97
- color: white;
98
- }
99
-
100
- .btn-primary:hover {
101
- background: var(--primary-dark);
102
- }
103
-
104
- .btn-secondary {
105
- background: var(--secondary);
106
- color: white;
107
- }
108
-
109
- .btn-secondary:hover {
110
- opacity: 0.9;
111
- }
112
-
113
- .btn-outline {
114
- background: transparent;
115
- border: 2px solid var(--border);
116
- color: var(--text);
117
- }
118
-
119
- .btn-outline:hover {
120
- background: var(--bg);
121
- }
122
-
123
- .btn-quiz {
124
- background: var(--primary);
125
- color: white;
126
- padding: 8px 16px;
127
- font-size: 14px;
128
- }
129
-
130
- /* Input */
131
- input[type="text"] {
132
- width: 100%;
133
- padding: 12px 16px;
134
- font-size: 16px;
135
- border: 2px solid var(--border);
136
- border-radius: 8px;
137
- margin-bottom: 12px;
138
- transition: border-color 0.2s;
139
- }
140
-
141
- input[type="text"]:focus {
142
- outline: none;
143
- border-color: var(--primary);
144
- }
145
-
146
- /* Auth Section */
147
- #login-form {
148
- text-align: center;
149
- }
150
-
151
- #login-form p {
152
- margin-bottom: 16px;
153
- color: var(--text-light);
154
- }
155
-
156
- #login-form .btn-primary {
157
- width: 100%;
158
- }
159
-
160
- /* User Info */
161
- .user-info {
162
- background: var(--bg);
163
- padding: 16px;
164
- border-radius: 8px;
165
- margin-bottom: 20px;
166
- }
167
-
168
- .user-info p {
169
- display: flex;
170
- justify-content: space-between;
171
- margin-bottom: 8px;
172
- }
173
-
174
- .user-info p:last-child {
175
- margin-bottom: 0;
176
- }
177
-
178
- /* Quiz List */
179
- .quiz-list h3 {
180
- margin-bottom: 16px;
181
- }
182
-
183
- .time-slot {
184
- margin-bottom: 16px;
185
- }
186
-
187
- .time-slot h4 {
188
- font-size: 14px;
189
- color: var(--text-light);
190
- margin-bottom: 8px;
191
- }
192
-
193
- .quiz-item {
194
- display: flex;
195
- justify-content: space-between;
196
- align-items: center;
197
- padding: 12px;
198
- background: var(--bg);
199
- border-radius: 8px;
200
- margin-bottom: 8px;
201
- }
202
-
203
- .quiz-item .subject {
204
- font-weight: 600;
205
- }
206
-
207
- /* Actions */
208
- .actions {
209
- display: flex;
210
- gap: 12px;
211
- margin-top: 16px;
212
- }
213
-
214
- .actions button {
215
- flex: 1;
216
- }
217
-
218
- /* Quiz Header */
219
- .quiz-header {
220
- display: flex;
221
- justify-content: space-between;
222
- align-items: center;
223
- padding: 16px;
224
- background: var(--card-bg);
225
- border-radius: 12px;
226
- margin-bottom: 16px;
227
- box-shadow: var(--shadow);
228
- }
229
-
230
- .header-left {
231
- display: flex;
232
- gap: 16px;
233
- }
234
-
235
- #subject-name {
236
- font-weight: 600;
237
- color: var(--primary);
238
- }
239
-
240
- #question-counter {
241
- color: var(--text-light);
242
- }
243
-
244
- /* Timer */
245
- .timer {
246
- background: var(--primary);
247
- color: white;
248
- padding: 8px 16px;
249
- border-radius: 8px;
250
- font-weight: 600;
251
- font-size: 18px;
252
- }
253
-
254
- .timer.warning {
255
- background: var(--danger);
256
- animation: pulse 1s infinite;
257
- }
258
-
259
- @keyframes pulse {
260
- 0%, 100% { opacity: 1; }
261
- 50% { opacity: 0.7; }
262
- }
263
-
264
- /* Question Area */
265
- .question-area {
266
- margin-bottom: 24px;
267
- }
268
-
269
- .difficulty-badge {
270
- display: inline-block;
271
- padding: 4px 12px;
272
- background: var(--secondary);
273
- color: white;
274
- border-radius: 16px;
275
- font-size: 12px;
276
- margin-bottom: 12px;
277
- }
278
-
279
- .question-text {
280
- font-size: 18px;
281
- line-height: 1.8;
282
- }
283
-
284
- /* Answer Area */
285
- .answer-area {
286
- margin-bottom: 24px;
287
- }
288
-
289
- .answer-area input {
290
- margin-bottom: 12px;
291
- }
292
-
293
- .answer-area .btn-primary {
294
- width: 100%;
295
- }
296
-
297
- /* Choices (4択選択肢) */
298
- .choices-container {
299
- display: flex;
300
- flex-direction: column;
301
- gap: 12px;
302
- }
303
-
304
- .choice-btn {
305
- width: 100%;
306
- padding: 16px 20px;
307
- text-align: left;
308
- font-size: 16px;
309
- font-weight: 500;
310
- line-height: 1.5;
311
- background: var(--card-bg);
312
- border: 2px solid var(--border);
313
- border-radius: 12px;
314
- color: var(--text);
315
- transition: all 0.2s ease;
316
- }
317
-
318
- .choice-btn:hover {
319
- background: var(--bg);
320
- border-color: var(--primary);
321
- }
322
-
323
- .choice-btn.selected {
324
- background: var(--primary);
325
- border-color: var(--primary);
326
- color: white;
327
- }
328
-
329
- .choice-btn:active {
330
- transform: scale(0.98);
331
- }
332
-
333
- /* Navigation */
334
- .navigation {
335
- display: flex;
336
- gap: 12px;
337
- }
338
-
339
- .navigation button {
340
- flex: 1;
341
- }
342
-
343
- /* Result Summary */
344
- .score-display {
345
- text-align: center;
346
- margin-bottom: 24px;
347
- }
348
-
349
- .score-circle {
350
- display: inline-flex;
351
- align-items: baseline;
352
- font-size: 48px;
353
- font-weight: 700;
354
- color: var(--primary);
355
- }
356
-
357
- .score-divider {
358
- font-size: 32px;
359
- margin: 0 4px;
360
- color: var(--text-light);
361
- }
362
-
363
- .score-total {
364
- font-size: 32px;
365
- color: var(--text-light);
366
- }
367
-
368
- .score-label {
369
- color: var(--text-light);
370
- margin-top: 8px;
371
- }
372
-
373
- /* Points Breakdown */
374
- .points-breakdown {
375
- background: var(--bg);
376
- padding: 16px;
377
- border-radius: 8px;
378
- }
379
-
380
- .point-row {
381
- display: flex;
382
- justify-content: space-between;
383
- padding: 8px 0;
384
- border-bottom: 1px solid var(--border);
385
- }
386
-
387
- .point-row:last-child {
388
- border-bottom: none;
389
- }
390
-
391
- .point-row.total {
392
- font-weight: 700;
393
- font-size: 18px;
394
- color: var(--primary);
395
- }
396
-
397
- /* Answer Details */
398
- .answer-item {
399
- padding: 16px;
400
- margin-bottom: 12px;
401
- border-radius: 8px;
402
- border-left: 4px solid;
403
- }
404
-
405
- .answer-item.correct {
406
- background: #ECFDF5;
407
- border-color: var(--success);
408
- }
409
-
410
- .answer-item.incorrect {
411
- background: #FEF2F2;
412
- border-color: var(--danger);
413
- }
414
-
415
- .answer-header {
416
- display: flex;
417
- align-items: center;
418
- gap: 8px;
419
- margin-bottom: 8px;
420
- }
421
-
422
- .status-icon {
423
- font-size: 20px;
424
- font-weight: 700;
425
- }
426
-
427
- .correct .status-icon {
428
- color: var(--success);
429
- }
430
-
431
- .incorrect .status-icon {
432
- color: var(--danger);
433
- }
434
-
435
- .question-number {
436
- font-weight: 600;
437
- color: var(--text-light);
438
- }
439
-
440
- .answer-content .question-text {
441
- font-size: 14px;
442
- margin-bottom: 8px;
443
- }
444
-
445
- .answer-comparison {
446
- font-size: 14px;
447
- }
448
-
449
- .answer-comparison .label {
450
- color: var(--text-light);
451
- margin-right: 8px;
452
- }
453
-
454
- .your-answer,
455
- .correct-answer {
456
- margin-bottom: 4px;
457
- }
458
-
459
- .correct-answer .value {
460
- color: var(--success);
461
- font-weight: 600;
462
- }
463
-
464
- /* Tabs */
465
- .tab-container {
466
- display: flex;
467
- gap: 8px;
468
- margin-bottom: 16px;
469
- overflow-x: auto;
470
- padding-bottom: 4px;
471
- }
472
-
473
- .tab {
474
- flex: 1;
475
- min-width: 60px;
476
- padding: 8px 12px;
477
- background: var(--card-bg);
478
- border: 2px solid var(--border);
479
- border-radius: 8px;
480
- font-size: 14px;
481
- white-space: nowrap;
482
- }
483
-
484
- .tab.active {
485
- background: var(--primary);
486
- color: white;
487
- border-color: var(--primary);
488
- }
489
-
490
- /* Ranking List */
491
- .ranking-list {
492
- list-style: none;
493
- }
494
-
495
- .ranking-item {
496
- display: flex;
497
- align-items: center;
498
- padding: 12px;
499
- border-bottom: 1px solid var(--border);
500
- }
501
-
502
- .ranking-item:last-child {
503
- border-bottom: none;
504
- }
505
-
506
- .ranking-item .rank {
507
- width: 40px;
508
- font-size: 18px;
509
- font-weight: 700;
510
- text-align: center;
511
- }
512
-
513
- .ranking-item .name {
514
- flex: 1;
515
- margin-left: 12px;
516
- }
517
-
518
- .ranking-item .points {
519
- font-weight: 600;
520
- color: var(--primary);
521
- }
522
-
523
- .ranking-item.rank-1 {
524
- background: #FEF3C7;
525
- }
526
-
527
- .ranking-item.rank-2 {
528
- background: #F3F4F6;
529
- }
530
-
531
- .ranking-item.rank-3 {
532
- background: #FED7AA;
533
- }
534
-
535
- .ranking-item.is-me {
536
- border: 2px solid var(--primary);
537
- border-radius: 8px;
538
- }
539
-
540
- /* My Rank */
541
- #my-rank {
542
- display: flex;
543
- align-items: baseline;
544
- justify-content: center;
545
- gap: 4px;
546
- }
547
-
548
- .rank-number {
549
- font-size: 48px;
550
- font-weight: 700;
551
- color: var(--primary);
552
- }
553
-
554
- .rank-suffix {
555
- font-size: 24px;
556
- color: var(--text-light);
557
- }
558
-
559
- .rank-points {
560
- margin-left: 16px;
561
- font-size: 18px;
562
- color: var(--text-light);
563
- }
564
-
565
- /* Utility */
566
- .hidden {
567
- display: none !important;
568
- }
569
-
570
- .error {
571
- color: var(--danger);
572
- text-align: center;
573
- padding: 8px;
574
- }
575
-
576
- .loading-text,
577
- .no-data {
578
- text-align: center;
579
- color: var(--text-light);
580
- padding: 24px;
581
- }
582
-
583
- /* Footer */
584
- footer {
585
- text-align: center;
586
- padding: 24px;
587
- color: var(--text-light);
588
- font-size: 12px;
589
- }
590
-
591
- /* Responsive */
592
- @media (max-width: 360px) {
593
- .container {
594
- padding: 12px;
595
- }
596
-
597
- .card {
598
- padding: 16px;
599
- }
600
-
601
- header h1 {
602
- font-size: 24px;
603
- }
604
- }
 
1
+ /* カスタムスタイル(Tailwind拡張、アニメーション) */
2
+
3
+ body {
4
+ background-color: #f9f8f4;
5
+ color: #5c504a;
6
+ }
7
+
8
+ /* ノイズテクスチャの適用 */
9
+ .bg-texture::before {
10
+ content: "";
11
+ position: fixed;
12
+ top: 0;
13
+ left: 0;
14
+ width: 100%;
15
+ height: 100%;
16
+ pointer-events: none;
17
+ z-index: -1;
18
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.08'/%3E%3C/svg%3E");
19
+ opacity: 0.6;
20
+ }
21
+
22
+ /* ふわっと表示 */
23
+ .fade-in {
24
+ animation: fadeIn 0.8s cubic-bezier(0.22, 1, 0.36, 1);
25
+ }
26
+
27
+ @keyframes fadeIn {
28
+ from {
29
+ opacity: 0;
30
+ transform: translateY(15px);
31
+ }
32
+ to {
33
+ opacity: 1;
34
+ transform: translateY(0);
35
+ }
36
+ }
37
+
38
+ /* ゆっくり動く背景オーブ */
39
+ .orb {
40
+ position: absolute;
41
+ border-radius: 50%;
42
+ filter: blur(60px);
43
+ z-index: -2;
44
+ animation: float 10s infinite ease-in-out;
45
+ opacity: 0.4;
46
+ }
47
+
48
+ @keyframes float {
49
+ 0% {
50
+ transform: translate(0, 0);
51
+ }
52
+ 50% {
53
+ transform: translate(10px, -15px);
54
+ }
55
+ 100% {
56
+ transform: translate(0, 0);
57
+ }
58
+ }
59
+
60
+ /* レーダーチャートのサイズ調整 */
61
+ .chart-container {
62
+ position: relative;
63
+ height: 200px;
64
+ width: 100%;
65
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html CHANGED
@@ -3,214 +3,68 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>超天才クイズ - 配信モード</title>
7
- <link rel="stylesheet" href="css/style.css">
8
- </head>
9
- <body>
10
- <div class="container">
11
- <header>
12
- <h1>超天才クイズ</h1>
13
- <p class="subtitle">配信モード v2.0.0</p>
14
- </header>
15
-
16
- <main id="main-content">
17
- <!-- ログイン/登録フォーム -->
18
- <section id="auth-section" class="card">
19
- <h2>ようこそ!</h2>
20
-
21
- <div id="login-form">
22
- <p>お名前を入力してください</p>
23
- <input type="text" id="user-name" placeholder="お名前(ひらがな可)" maxlength="20">
24
- <button id="start-btn" class="btn-primary">はじめる</button>
25
- </div>
26
-
27
- <div id="loading" class="hidden">
28
- <p>ログイン中...</p>
29
- </div>
30
-
31
- <div id="error-message" class="error hidden"></div>
32
- </section>
33
-
34
- <!-- ログイン済み表示 -->
35
- <section id="logged-in-section" class="card hidden">
36
- <h2>こんにちは、<span id="display-name"></span>さん!</h2>
37
-
38
- <div class="user-info">
39
- <p>クラス: <span id="user-class">天才のたまご</span></p>
40
- <p>今週のポイント: <span id="weekly-points">0</span>pt</p>
41
- </div>
42
-
43
- <div class="quiz-list" id="quiz-list">
44
- <h3>今日の配信</h3>
45
- <div id="available-quizzes">
46
- <!-- 配信問題リストがここに表示される -->
47
- <p class="loading-text">配信情報を取得中...</p>
48
- </div>
49
- </div>
50
-
51
- <div class="actions">
52
- <button id="ranking-btn" class="btn-secondary">ランキングを見る</button>
53
- <button id="logout-btn" class="btn-outline">ログアウト</button>
54
- </div>
55
- </section>
56
- </main>
57
-
58
- <footer>
59
- <p>&copy; 2025 超天才クイズ</p>
60
- </footer>
61
- </div>
62
-
63
- <script src="js/api.js"></script>
64
- <script src="js/auth.js"></script>
65
  <script>
66
- // ページ読み込み時の処理
67
- document.addEventListener('DOMContentLoaded', async () => {
68
- // トークンチェック
69
- const isLoggedIn = await Auth.checkLogin();
70
-
71
- if (isLoggedIn) {
72
- showLoggedInSection();
73
- } else {
74
- showAuthSection();
75
- }
76
- });
77
-
78
- // ログインフォーム表示
79
- function showAuthSection() {
80
- document.getElementById('auth-section').classList.remove('hidden');
81
- document.getElementById('logged-in-section').classList.add('hidden');
82
- }
83
-
84
- // ログイン済み表示
85
- async function showLoggedInSection() {
86
- document.getElementById('auth-section').classList.add('hidden');
87
- document.getElementById('logged-in-section').classList.remove('hidden');
88
-
89
- const user = Auth.getUser();
90
- document.getElementById('display-name').textContent = user.name || 'ユーザー';
91
-
92
- // プロフィール取得
93
- try {
94
- const profile = await API.getUserProfile(user.id);
95
- if (profile.success && profile.profile) {
96
- document.getElementById('user-class').textContent =
97
- getClassName(profile.profile.current_class || 1);
98
- document.getElementById('weekly-points').textContent =
99
- profile.profile.weekly_points || 0;
100
- }
101
- } catch (e) {
102
- console.error('Profile fetch error:', e);
103
- }
104
-
105
- // 今日の配信を取得
106
- loadTodayQuizzes();
107
- }
108
-
109
- // クラス名取得
110
- function getClassName(level) {
111
- const classes = {
112
- 1: '天才のたまご',
113
- 2: '天才の見習い',
114
- 3: '天才かも',
115
- 4: 'もうすぐ天才',
116
- 5: '天才',
117
- 6: '超天才'
118
- };
119
- return classes[level] || '天才のたまご';
120
- }
121
-
122
- // 今日の配信を読み込み
123
- async function loadTodayQuizzes() {
124
- const container = document.getElementById('available-quizzes');
125
-
126
- try {
127
- // 今日の日付でクイズを取得(AMとPM)- 日本時間(UTC+9)基準
128
- const now = new Date();
129
- const jstTime = new Date(now.getTime() + 9 * 60 * 60 * 1000); // UTC+9
130
- const today = jstTime.toISOString().split('T')[0];
131
- console.log('[DEBUG] JST date:', today); // デバッグ用
132
- const subjects = ['jp', 'math', 'sci', 'soc'];
133
- const subjectNames = {
134
- jp: '国語',
135
- math: '算数',
136
- sci: '理科',
137
- soc: '社会'
138
- };
139
-
140
- let html = '';
141
- for (const slot of ['AM', 'PM']) {
142
- html += `<div class="time-slot"><h4>${slot === 'AM' ? '午前' : '午後'}の問題</h4>`;
143
-
144
- for (const subject of (slot === 'AM' ? ['jp', 'math'] : ['sci', 'soc'])) {
145
- const quizId = `${today}-${slot}-${subject}`;
146
- html += `
147
- <div class="quiz-item">
148
- <span class="subject">${subjectNames[subject]}</span>
149
- <button class="btn-quiz" onclick="startQuiz('${quizId}')">
150
- 挑戦する
151
- </button>
152
- </div>
153
- `;
154
- }
155
- html += '</div>';
156
  }
157
-
158
- container.innerHTML = html;
159
-
160
- } catch (e) {
161
- container.innerHTML = '<p class="error">配信情報の取得に失敗しました</p>';
162
- console.error('Quiz list error:', e);
163
  }
164
  }
 
165
 
166
- // クイズ開始
167
- function startQuiz(quizId) {
168
- window.location.href = `quiz.html?d=${quizId}`;
169
- }
170
-
171
- // イベントリスナー
172
- document.getElementById('start-btn').addEventListener('click', async () => {
173
- const name = document.getElementById('user-name').value.trim();
174
-
175
- if (!name) {
176
- showError('お名前を入力してください');
177
- return;
178
- }
179
-
180
- document.getElementById('login-form').classList.add('hidden');
181
- document.getElementById('loading').classList.remove('hidden');
182
-
183
- try {
184
- const result = await Auth.login(name);
185
- if (result.success) {
186
- showLoggedInSection();
187
- } else {
188
- showError(result.error || 'ログインに失敗しました');
189
- document.getElementById('login-form').classList.remove('hidden');
190
- document.getElementById('loading').classList.add('hidden');
191
- }
192
- } catch (e) {
193
- showError('エラーが発生しました');
194
- document.getElementById('login-form').classList.remove('hidden');
195
- document.getElementById('loading').classList.add('hidden');
196
- }
197
- });
198
-
199
- document.getElementById('ranking-btn').addEventListener('click', () => {
200
- window.location.href = 'ranking.html';
201
- });
202
-
203
- document.getElementById('logout-btn').addEventListener('click', () => {
204
- Auth.logout();
205
- showAuthSection();
206
- });
207
-
208
- function showError(message) {
209
- const errorEl = document.getElementById('error-message');
210
- errorEl.textContent = message;
211
- errorEl.classList.remove('hidden');
212
- setTimeout(() => errorEl.classList.add('hidden'), 3000);
213
- }
214
  </script>
215
  </body>
216
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>超天才クイズ - 配信モード v2.0.0</title>
7
+
8
+ <!-- React & ReactDOM -->
9
+ <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
10
+ <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
11
+ <!-- Babel (for JSX) -->
12
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
13
+ <!-- Tailwind CSS -->
14
+ <script src="https://cdn.tailwindcss.com"></script>
15
+ <!-- Google Fonts -->
16
+ <link rel="preconnect" href="https://fonts.googleapis.com">
17
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
18
+ <link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@400;500;700&family=Zen+Maru+Gothic:wght@400;500;700&display=swap" rel="stylesheet">
19
+
20
+ <!-- Tailwind Config -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  <script>
22
+ tailwind.config = {
23
+ theme: {
24
+ extend: {
25
+ colors: {
26
+ 'base-beige': '#f9f8f4',
27
+ 'accent-brown': '#5c504a',
28
+ 'soft-brown': '#9d918b',
29
+ 'gold': '#c5a065',
30
+ 'subject-jp': '#d68c8c',
31
+ 'subject-math': '#8badce',
32
+ 'subject-sci': '#96cbb2',
33
+ 'subject-soc': '#e3d296',
34
+ },
35
+ fontFamily: {
36
+ 'serif': ['"Shippori Mincho"', 'serif'],
37
+ 'sans': ['"Zen Maru Gothic"', 'sans-serif'],
38
+ },
39
+ boxShadow: {
40
+ 'soft': '0 20px 40px -10px rgba(92, 80, 74, 0.1)',
41
+ 'inner-light': 'inset 0 2px 4px 0 rgba(255, 255, 255, 0.5)',
42
+ 'glass': '0 8px 32px 0 rgba(31, 38, 135, 0.05)',
43
+ },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
 
 
 
 
 
 
45
  }
46
  }
47
+ </script>
48
 
49
+ <!-- Custom CSS -->
50
+ <link rel="stylesheet" href="css/style.css">
51
+ </head>
52
+ <body class="bg-texture font-sans antialiased">
53
+ <div id="root"></div>
54
+
55
+ <!-- JavaScript Modules (順序重要) -->
56
+ <script src="js/config.js"></script>
57
+ <script src="js/sessionManager.js"></script>
58
+ <script src="js/apiClient.js"></script>
59
+
60
+ <!-- React Components (Babel必要) -->
61
+ <script type="text/babel" src="js/icons.js"></script>
62
+ <script type="text/babel" src="js/components.js"></script>
63
+
64
+ <!-- App Initialization -->
65
+ <script type="text/babel">
66
+ const root = ReactDOM.createRoot(document.getElementById('root'));
67
+ root.render(<App />);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  </script>
69
  </body>
70
  </html>
js/api.js DELETED
@@ -1,190 +0,0 @@
1
- /**
2
- * 超天才クイズ v2.0.0 - API Client
3
- *
4
- * GAS APIとの通信を担当
5
- */
6
-
7
- const API = {
8
- // GAS API URL(v2用デプロイメント @54 - 一括生成・検証パート追加)
9
- BASE_URL: 'https://script.google.com/macros/s/AKfycbyo8CX6bDA4uP2sx-Jb1USt6l_615ACFYGpn4Bf_whpOZEhEKO6J8neHVjDunVGDnj8/exec',
10
-
11
- /**
12
- * GAS APIを呼び出し
13
- *
14
- * @param {string} action - アクション名
15
- * @param {Object} params - パラメータ
16
- * @returns {Promise<Object>} - APIレスポンス
17
- */
18
- async call(action, params = {}) {
19
- const url = new URL(this.BASE_URL);
20
- url.searchParams.set('action', action);
21
-
22
- // パラメータを追加
23
- for (const [key, value] of Object.entries(params)) {
24
- if (value !== undefined && value !== null) {
25
- url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
26
- }
27
- }
28
-
29
- try {
30
- const response = await fetch(url.toString(), {
31
- method: 'GET',
32
- mode: 'cors'
33
- });
34
-
35
- if (!response.ok) {
36
- throw new Error(`HTTP error: ${response.status}`);
37
- }
38
-
39
- return await response.json();
40
- } catch (error) {
41
- console.error('API call error:', error);
42
- return { success: false, error: error.message };
43
- }
44
- },
45
-
46
- /**
47
- * POSTリクエスト(大きなデータ送信用)
48
- *
49
- * @param {string} action - アクション名
50
- * @param {Object} data - 送信データ
51
- * @returns {Promise<Object>} - APIレスポンス
52
- */
53
- async post(action, data = {}) {
54
- try {
55
- // text/plain を使用してプリフライト(OPTIONS)を回避
56
- // GASはOPTIONSリクエストを処理できないため
57
- const response = await fetch(this.BASE_URL, {
58
- method: 'POST',
59
- mode: 'cors',
60
- headers: {
61
- 'Content-Type': 'text/plain'
62
- },
63
- body: JSON.stringify({ action, ...data })
64
- });
65
-
66
- if (!response.ok) {
67
- throw new Error(`HTTP error: ${response.status}`);
68
- }
69
-
70
- return await response.json();
71
- } catch (error) {
72
- console.error('API post error:', error);
73
- return { success: false, error: error.message };
74
- }
75
- },
76
-
77
- // ======================================
78
- // 認証API
79
- // ======================================
80
-
81
- /**
82
- * トークンを検証
83
- *
84
- * @param {string} token - トークン
85
- * @returns {Promise<Object>} - { success, is_valid, user_id }
86
- */
87
- async verifyToken(token) {
88
- return this.call('v2_verify_token', { token });
89
- },
90
-
91
- /**
92
- * トークンを生成
93
- *
94
- * @param {string} userId - ユーザーID
95
- * @returns {Promise<Object>} - { success, token, expires_at }
96
- */
97
- async generateToken(userId) {
98
- return this.call('v2_generate_token', { user_id: userId });
99
- },
100
-
101
- /**
102
- * ユーザー登録(v1のregister_userを流用)
103
- *
104
- * @param {string} name - ユーザー名
105
- * @returns {Promise<Object>} - { success, user_id }
106
- */
107
- async registerUser(name) {
108
- return this.call('register_user', { username: name });
109
- },
110
-
111
- // ======================================
112
- // 配信API
113
- // ======================================
114
-
115
- /**
116
- * 配信問題を取得
117
- *
118
- * @param {string} quizId - クイズID
119
- * @param {string} userId - ユーザーID
120
- * @returns {Promise<Object>} - { success, quiz, questions }
121
- */
122
- async getDeliveryQuiz(quizId, userId) {
123
- return this.call('v2_get_delivery', { quiz_id: quizId, user_id: userId });
124
- },
125
-
126
- /**
127
- * 回答を送信
128
- *
129
- * @param {string} userId - ユーザーID
130
- * @param {string} quizId - クイズID
131
- * @param {Array} answers - 回答リスト
132
- * @param {number} timeRemaining - 残り時間(秒)
133
- * @returns {Promise<Object>} - { success, result }
134
- */
135
- async submitDeliveryAnswers(userId, quizId, answers, timeRemaining) {
136
- return this.post('v2_submit_delivery', {
137
- user_id: userId,
138
- quiz_id: quizId,
139
- answers: answers,
140
- time_remaining: timeRemaining
141
- });
142
- },
143
-
144
- // ======================================
145
- // ランキングAPI
146
- // ======================================
147
-
148
- /**
149
- * ランキングを取得
150
- *
151
- * @param {string} date - 日付(YYYY-MM-DD)
152
- * @param {string} type - ランキング種類(total/jp/math/sci/soc)
153
- * @returns {Promise<Object>} - { success, rankings }
154
- */
155
- async getRanking(date, type) {
156
- return this.call('v2_get_ranking', { date, type });
157
- },
158
-
159
- // ======================================
160
- // ユーザーAPI
161
- // ======================================
162
-
163
- /**
164
- * ユーザープロフィールを取得
165
- *
166
- * @param {string} userId - ユーザーID
167
- * @returns {Promise<Object>} - { success, profile }
168
- */
169
- async getUserProfile(userId) {
170
- return this.call('v2_get_user_profile', { user_id: userId });
171
- },
172
-
173
- // ======================================
174
- // ステータスAPI
175
- // ======================================
176
-
177
- /**
178
- * v2システム状態を確認
179
- *
180
- * @returns {Promise<Object>} - { success, status }
181
- */
182
- async checkStatus() {
183
- return this.call('v2_check_status');
184
- }
185
- };
186
-
187
- // デバッグ用
188
- if (typeof window !== 'undefined') {
189
- window.API = API;
190
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/apiClient.js ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // --- APIクライアント(v2.0.0 配信モード用) ---
2
+ const ApiClient = {
3
+ /**
4
+ * GAS API呼び出し(GET形式)
5
+ * @param {string} action - アクション名
6
+ * @param {Object} params - パラメータ
7
+ * @param {string} operationName - 操作名(ログ用)
8
+ * @returns {Promise<Object>} APIレスポンス
9
+ */
10
+ async _callApi(action, params, operationName) {
11
+ console.log(`[ApiClient] ${operationName} - Request:`, { action, params });
12
+
13
+ try {
14
+ const controller = new AbortController();
15
+ const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.TIMEOUT);
16
+
17
+ const url = new URL(API_CONFIG.BASE_URL);
18
+ url.searchParams.set('action', action);
19
+
20
+ // パラメータを追加
21
+ for (const [key, value] of Object.entries(params)) {
22
+ if (value !== undefined && value !== null) {
23
+ url.searchParams.set(key, typeof value === 'object' ? JSON.stringify(value) : value);
24
+ }
25
+ }
26
+
27
+ const response = await fetch(url.toString(), {
28
+ method: 'GET',
29
+ mode: 'cors',
30
+ signal: controller.signal
31
+ });
32
+
33
+ clearTimeout(timeoutId);
34
+
35
+ if (!response.ok) {
36
+ console.error(`[ApiClient] ${operationName} - HTTP Error:`, response.status);
37
+ return {
38
+ success: false,
39
+ error: { code: 'HTTP_ERROR', message: `HTTPエラー: ${response.status}` }
40
+ };
41
+ }
42
+
43
+ const result = await response.json();
44
+ console.log(`[ApiClient] ${operationName} - Response:`, result);
45
+
46
+ // GAS形式の変換
47
+ if (result.status === 'success') {
48
+ return { success: true, data: result.data || result, ...result };
49
+ } else if (result.status === 'error') {
50
+ return { success: false, error: { message: result.message || 'APIエラー' } };
51
+ }
52
+
53
+ return result;
54
+
55
+ } catch (error) {
56
+ console.error(`[ApiClient] ${operationName} - Error:`, error);
57
+
58
+ let errorMessage = 'APIとの通信に失敗しました';
59
+ if (error.name === 'AbortError') {
60
+ errorMessage = '通信がタイムアウトしました';
61
+ }
62
+
63
+ return {
64
+ success: false,
65
+ error: { code: 'NETWORK_ERROR', message: errorMessage }
66
+ };
67
+ }
68
+ },
69
+
70
+ /**
71
+ * GAS API呼び出し(POST形式 - 大きなデータ送信用)
72
+ * @param {string} action - アクション名
73
+ * @param {Object} data - 送信データ
74
+ * @param {string} operationName - 操作名(ログ用)
75
+ * @returns {Promise<Object>} APIレスポンス
76
+ */
77
+ async _postApi(action, data, operationName) {
78
+ console.log(`[ApiClient] ${operationName} - POST Request:`, { action, data });
79
+
80
+ try {
81
+ const controller = new AbortController();
82
+ const timeoutId = setTimeout(() => controller.abort(), API_CONFIG.TIMEOUT);
83
+
84
+ // text/plainでプリフライト回避(GASはOPTIONS未対応)
85
+ const response = await fetch(API_CONFIG.BASE_URL, {
86
+ method: 'POST',
87
+ mode: 'cors',
88
+ headers: { 'Content-Type': 'text/plain' },
89
+ body: JSON.stringify({ action, ...data }),
90
+ signal: controller.signal
91
+ });
92
+
93
+ clearTimeout(timeoutId);
94
+
95
+ if (!response.ok) {
96
+ console.error(`[ApiClient] ${operationName} - HTTP Error:`, response.status);
97
+ return {
98
+ success: false,
99
+ error: { code: 'HTTP_ERROR', message: `HTTPエラー: ${response.status}` }
100
+ };
101
+ }
102
+
103
+ const result = await response.json();
104
+ console.log(`[ApiClient] ${operationName} - Response:`, result);
105
+
106
+ return result;
107
+
108
+ } catch (error) {
109
+ console.error(`[ApiClient] ${operationName} - Error:`, error);
110
+ return {
111
+ success: false,
112
+ error: { code: 'NETWORK_ERROR', message: 'APIとの通信に失敗しました' }
113
+ };
114
+ }
115
+ },
116
+
117
+ // ======================================
118
+ // 認証API
119
+ // ======================================
120
+
121
+ /**
122
+ * ユーザー登録
123
+ * @param {string} username - ユーザー名
124
+ * @param {string} password - パスワード(v2では未使用、互換性維持)
125
+ * @param {string} inviteCode - 招待コード(v2では未使用、互換性維持)
126
+ * @returns {Promise<Object>}
127
+ */
128
+ async registerUser(username, password = '', inviteCode = '') {
129
+ const result = await this._callApi('register_user', { username }, 'registerUser');
130
+
131
+ if (result.success || result.status === 'success') {
132
+ // v2形式に統一
133
+ return {
134
+ success: true,
135
+ data: {
136
+ user_id: result.user_id || result.data?.user_id,
137
+ username: username
138
+ }
139
+ };
140
+ }
141
+ return result;
142
+ },
143
+
144
+ /**
145
+ * ログイン(v2ではregisterUserと同じ - 簡易認証)
146
+ * @param {string} username - ユーザー名
147
+ * @param {string} password - パスワード(v2では未使用)
148
+ * @returns {Promise<Object>}
149
+ */
150
+ async login(username, password = '') {
151
+ // v2は簡易認証なのでregisterUserを流用
152
+ return this.registerUser(username, password);
153
+ },
154
+
155
+ // ======================================
156
+ // 配信API(v2専用)
157
+ // ======================================
158
+
159
+ /**
160
+ * 今日の配信クイズ一覧を取得
161
+ * @param {string} userId - ユーザーID
162
+ * @returns {Promise<Object>}
163
+ */
164
+ async getTodayQuizzes(userId) {
165
+ return this._callApi('v2_get_today_quizzes', { user_id: userId }, 'getTodayQuizzes');
166
+ },
167
+
168
+ /**
169
+ * 配信クイズを取得
170
+ * @param {string} quizId - クイズID
171
+ * @param {string} userId - ユーザーID
172
+ * @returns {Promise<Object>}
173
+ */
174
+ async getDeliveryQuiz(quizId, userId) {
175
+ return this._callApi('v2_get_delivery', { quiz_id: quizId, user_id: userId }, 'getDeliveryQuiz');
176
+ },
177
+
178
+ /**
179
+ * 回答を送信
180
+ * @param {string} userId - ユーザーID
181
+ * @param {string} quizId - クイズID
182
+ * @param {Array} answers - 回答リスト
183
+ * @param {number} timeRemaining - 残り時間(秒)
184
+ * @returns {Promise<Object>}
185
+ */
186
+ async submitDeliveryAnswers(userId, quizId, answers, timeRemaining) {
187
+ return this._postApi('v2_submit_delivery', {
188
+ user_id: userId,
189
+ quiz_id: quizId,
190
+ answers: answers,
191
+ time_remaining: timeRemaining
192
+ }, 'submitDeliveryAnswers');
193
+ },
194
+
195
+ // ======================================
196
+ // ランキングAPI
197
+ // ======================================
198
+
199
+ /**
200
+ * ランキングを取得
201
+ * @param {string} date - 日付(YYYY-MM-DD)
202
+ * @param {string} type - ランキング種類(total/jp/math/sci/soc)
203
+ * @returns {Promise<Object>}
204
+ */
205
+ async getRanking(date, type = 'total') {
206
+ return this._callApi('v2_get_ranking', { date, type }, 'getRanking');
207
+ },
208
+
209
+ // ======================================
210
+ // ユーザーAPI
211
+ // ======================================
212
+
213
+ /**
214
+ * ユーザープロフィールを取得
215
+ * @param {string} userId - ユーザーID
216
+ * @returns {Promise<Object>}
217
+ */
218
+ async getUserProfile(userId) {
219
+ return this._callApi('v2_get_user_profile', { user_id: userId }, 'getUserProfile');
220
+ },
221
+
222
+ /**
223
+ * トークン検証
224
+ * @param {string} token - トークン
225
+ * @returns {Promise<Object>}
226
+ */
227
+ async verifyToken(token) {
228
+ return this._callApi('v2_verify_token', { token }, 'verifyToken');
229
+ },
230
+
231
+ /**
232
+ * トークン生成
233
+ * @param {string} userId - ユーザーID
234
+ * @returns {Promise<Object>}
235
+ */
236
+ async generateToken(userId) {
237
+ return this._callApi('v2_generate_token', { user_id: userId }, 'generateToken');
238
+ },
239
+
240
+ // ======================================
241
+ // システムAPI
242
+ // ======================================
243
+
244
+ /**
245
+ * v2システム状態確認
246
+ * @returns {Promise<Object>}
247
+ */
248
+ async checkStatus() {
249
+ return this._callApi('v2_check_status', {}, 'checkStatus');
250
+ }
251
+ };
js/auth.js DELETED
@@ -1,180 +0,0 @@
1
- /**
2
- * 超天才クイズ v2.0.0 - 認証モジュール
3
- *
4
- * トークン管理とログイン処理を担当
5
- */
6
-
7
- const Auth = {
8
- // localStorage キー
9
- STORAGE_KEY: 'cho_tensai_v2_user',
10
-
11
- /**
12
- * ログイン状態をチェック
13
- *
14
- * @returns {Promise<boolean>} - ログイン済みかどうか
15
- */
16
- async checkLogin() {
17
- const userData = this.getUser();
18
-
19
- if (!userData || !userData.token) {
20
- return false;
21
- }
22
-
23
- // トークンの有効期限をローカルでチェック
24
- if (userData.tokenExpiresAt) {
25
- const expiresAt = new Date(userData.tokenExpiresAt);
26
- if (new Date() > expiresAt) {
27
- this.logout();
28
- return false;
29
- }
30
- }
31
-
32
- // サーバーでトークン検証(オプション)
33
- try {
34
- const result = await API.verifyToken(userData.token);
35
- if (result.success && result.is_valid) {
36
- return true;
37
- } else {
38
- this.logout();
39
- return false;
40
- }
41
- } catch (e) {
42
- // オフライン時はローカルのトークンを信頼
43
- console.warn('Token verification failed, using local data');
44
- return true;
45
- }
46
- },
47
-
48
- /**
49
- * ログイン処理
50
- *
51
- * @param {string} name - ユーザー名
52
- * @returns {Promise<Object>} - { success, user }
53
- */
54
- async login(name) {
55
- try {
56
- // まずユーザー登録(既存ユーザーの場合はuser_id取得)
57
- const registerResult = await API.registerUser(name);
58
-
59
- // GASレスポンス形式: { status: "success", data: { user_id: "..." } }
60
- if (registerResult.status !== 'success') {
61
- return { success: false, error: registerResult.message || '登録に失敗しました' };
62
- }
63
-
64
- const userId = registerResult.data.user_id;
65
-
66
- // トークンを生成
67
- const tokenResult = await API.generateToken(userId);
68
-
69
- if (!tokenResult.success) {
70
- return { success: false, error: tokenResult.error || 'トークン生成に失敗しました' };
71
- }
72
-
73
- // ユーザー情報を保存
74
- const userData = {
75
- id: userId,
76
- name: name,
77
- token: tokenResult.token,
78
- tokenExpiresAt: tokenResult.expires_at,
79
- loginAt: new Date().toISOString()
80
- };
81
-
82
- this.saveUser(userData);
83
-
84
- return { success: true, user: userData };
85
-
86
- } catch (error) {
87
- console.error('Login error:', error);
88
- return { success: false, error: error.message };
89
- }
90
- },
91
-
92
- /**
93
- * URLパラメータのトークンを検証して保存
94
- *
95
- * @param {string} token - URLから取得したトークン
96
- * @returns {Promise<boolean>} - 成功したかどうか
97
- */
98
- async verifyAndSaveToken(token) {
99
- try {
100
- const result = await API.verifyToken(token);
101
-
102
- if (result.success && result.is_valid) {
103
- // プロフィールを取得してユーザー名を得る
104
- const profileResult = await API.getUserProfile(result.user_id);
105
-
106
- const userData = {
107
- id: result.user_id,
108
- name: profileResult.success ? (profileResult.profile?.display_name || 'ユーザー') : 'ユーザー',
109
- token: token,
110
- tokenExpiresAt: null, // サーバー側で管理
111
- loginAt: new Date().toISOString()
112
- };
113
-
114
- this.saveUser(userData);
115
- return true;
116
- }
117
-
118
- return false;
119
-
120
- } catch (error) {
121
- console.error('Token verification error:', error);
122
- return false;
123
- }
124
- },
125
-
126
- /**
127
- * ログアウト
128
- */
129
- logout() {
130
- localStorage.removeItem(this.STORAGE_KEY);
131
- },
132
-
133
- /**
134
- * ユーザー情報を取得
135
- *
136
- * @returns {Object|null} - ユーザー情報
137
- */
138
- getUser() {
139
- try {
140
- const data = localStorage.getItem(this.STORAGE_KEY);
141
- return data ? JSON.parse(data) : null;
142
- } catch (e) {
143
- return null;
144
- }
145
- },
146
-
147
- /**
148
- * ユーザー情報を保存
149
- *
150
- * @param {Object} userData - ユーザー情報
151
- */
152
- saveUser(userData) {
153
- localStorage.setItem(this.STORAGE_KEY, JSON.stringify(userData));
154
- },
155
-
156
- /**
157
- * トークンを取得
158
- *
159
- * @returns {string|null} - トークン
160
- */
161
- getToken() {
162
- const user = this.getUser();
163
- return user ? user.token : null;
164
- },
165
-
166
- /**
167
- * ユーザーIDを取得
168
- *
169
- * @returns {string|null} - ユーザーID
170
- */
171
- getUserId() {
172
- const user = this.getUser();
173
- return user ? user.id : null;
174
- }
175
- };
176
-
177
- // デバッグ用
178
- if (typeof window !== 'undefined') {
179
- window.Auth = Auth;
180
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/components.js ADDED
@@ -0,0 +1,875 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // --- v2.0.0 配信モード Reactコンポーネント ---
2
+ const { useState, useEffect, useRef } = React;
3
+
4
+ // ======================================
5
+ // 1. ログイン画面
6
+ // ======================================
7
+ const LoginScreen = ({ onStart }) => {
8
+ const [mode, setMode] = useState('select'); // 'select' | 'login' | 'register'
9
+ const [name, setName] = useState('');
10
+ const [password, setPassword] = useState('');
11
+ const [passwordConfirm, setPasswordConfirm] = useState('');
12
+ const [isLoading, setIsLoading] = useState(false);
13
+ const [error, setError] = useState('');
14
+
15
+ const handleLogin = async () => {
16
+ if (!name.trim()) {
17
+ setError('ユーザー名を入力してください');
18
+ return;
19
+ }
20
+ setError('');
21
+ setIsLoading(true);
22
+
23
+ try {
24
+ const result = await ApiClient.login(name.trim(), password);
25
+ console.log('[LoginScreen] ログイン結果:', result);
26
+
27
+ if (result.success) {
28
+ SessionManager.clearAll();
29
+ SessionManager.setUser(result.data);
30
+ onStart(name.trim());
31
+ } else {
32
+ setError(result.error?.message || 'ログインに失敗しました');
33
+ }
34
+ } catch (error) {
35
+ console.error('[LoginScreen] ログインエラー:', error);
36
+ setError('ログインに失敗しました');
37
+ } finally {
38
+ setIsLoading(false);
39
+ }
40
+ };
41
+
42
+ const handleRegister = async () => {
43
+ if (!name.trim()) {
44
+ setError('ユーザー名を入力してください');
45
+ return;
46
+ }
47
+ setError('');
48
+ setIsLoading(true);
49
+
50
+ try {
51
+ const result = await ApiClient.registerUser(name.trim(), password);
52
+ console.log('[LoginScreen] ユーザー登録結果:', result);
53
+
54
+ if (result.success) {
55
+ SessionManager.clearAll();
56
+ SessionManager.setUser(result.data);
57
+ onStart(name.trim());
58
+ } else {
59
+ setError(result.error?.message || '登録に失敗しました');
60
+ }
61
+ } catch (error) {
62
+ console.error('[LoginScreen] 登録エラー:', error);
63
+ setError('登録に失敗しました');
64
+ } finally {
65
+ setIsLoading(false);
66
+ }
67
+ };
68
+
69
+ return (
70
+ <div className="min-h-screen flex flex-col items-center justify-center p-6 fade-in relative overflow-hidden">
71
+ {/* 背景オーブ */}
72
+ <div className="orb w-64 h-64 bg-subject-soc top-10 -left-10 blur-3xl opacity-30"></div>
73
+ <div className="orb w-64 h-64 bg-subject-math bottom-10 -right-10 blur-3xl opacity-30" style={{animationDelay: '-5s'}}></div>
74
+
75
+ <div className="w-full max-w-sm bg-white/80 backdrop-blur-md rounded-[32px] shadow-glass p-10 text-center border border-white/60 relative">
76
+ <div className="relative z-10 flex flex-col items-center">
77
+ <div className="mb-6 p-4 bg-gradient-to-br from-white to-gray-50 rounded-full shadow-inner-light">
78
+ <Icons.Book className="w-16 h-16 text-gold" />
79
+ </div>
80
+
81
+ <h1 className="text-3xl font-serif font-bold text-accent-brown mb-3 tracking-widest">超天才クイズ</h1>
82
+ <p className="text-xs text-soft-brown mb-2 font-serif tracking-widest">配信モード v2.0.0</p>
83
+ <p className="text-xs text-soft-brown mb-10 font-serif tracking-widest leading-relaxed">
84
+ 毎日の問題に挑戦しよう
85
+ </p>
86
+
87
+ {/* モード選択 */}
88
+ {mode === 'select' && (
89
+ <div className="w-full space-y-4">
90
+ <button
91
+ onClick={() => setMode('login')}
92
+ className="w-full py-4 rounded-full bg-accent-brown text-white font-serif tracking-wider shadow-lg hover:shadow-xl hover:bg-gold transform hover:-translate-y-0.5 transition-all duration-300 active:scale-95"
93
+ >
94
+ ログイン
95
+ </button>
96
+ <button
97
+ onClick={() => setMode('register')}
98
+ className="w-full py-4 rounded-full bg-white border-2 border-accent-brown text-accent-brown font-serif tracking-wider shadow-md hover:shadow-lg hover:bg-accent-brown hover:text-white transform hover:-translate-y-0.5 transition-all duration-300 active:scale-95"
99
+ >
100
+ 新規登録
101
+ </button>
102
+ </div>
103
+ )}
104
+
105
+ {/* ログインモード */}
106
+ {mode === 'login' && (
107
+ <div className="w-full space-y-5">
108
+ <div className="relative group">
109
+ <label className="absolute -top-2.5 left-4 bg-white px-2 text-xs text-gold font-bold">ユーザー名</label>
110
+ <input
111
+ type="text"
112
+ value={name}
113
+ onChange={(e) => setName(e.target.value)}
114
+ placeholder="例: taro_yamada"
115
+ className="w-full bg-white border border-gray-200 rounded-xl px-5 py-4 focus:outline-none focus:border-gold/50 focus:ring-1 focus:ring-gold/50 transition-all text-accent-brown placeholder-gray-300 text-center font-serif text-lg shadow-sm"
116
+ />
117
+ </div>
118
+
119
+ {error && (
120
+ <p className="text-red-500 text-sm font-serif">{error}</p>
121
+ )}
122
+
123
+ <button
124
+ onClick={handleLogin}
125
+ disabled={isLoading}
126
+ className="w-full py-4 rounded-full bg-accent-brown text-white font-serif tracking-wider shadow-lg hover:shadow-xl hover:bg-gold transform hover:-translate-y-0.5 transition-all duration-300 active:scale-95 flex items-center justify-center space-x-2 disabled:opacity-50"
127
+ >
128
+ {isLoading ? (
129
+ <span className="animate-spin">...</span>
130
+ ) : (
131
+ <span>ログインする</span>
132
+ )}
133
+ </button>
134
+
135
+ <button
136
+ onClick={() => { setMode('select'); setError(''); }}
137
+ className="w-full py-2 text-soft-brown text-sm hover:text-accent-brown transition-colors"
138
+ >
139
+ 戻る
140
+ </button>
141
+ </div>
142
+ )}
143
+
144
+ {/* 新規登録モード */}
145
+ {mode === 'register' && (
146
+ <div className="w-full space-y-5">
147
+ <div className="relative group">
148
+ <label className="absolute -top-2.5 left-4 bg-white px-2 text-xs text-gold font-bold">ユーザー名</label>
149
+ <input
150
+ type="text"
151
+ value={name}
152
+ onChange={(e) => setName(e.target.value)}
153
+ placeholder="新しいユーザー名"
154
+ className="w-full bg-white border border-gray-200 rounded-xl px-5 py-4 focus:outline-none focus:border-gold/50 focus:ring-1 focus:ring-gold/50 transition-all text-accent-brown placeholder-gray-300 text-center font-serif text-lg shadow-sm"
155
+ />
156
+ </div>
157
+
158
+ {error && (
159
+ <p className="text-red-500 text-sm font-serif">{error}</p>
160
+ )}
161
+
162
+ <button
163
+ onClick={handleRegister}
164
+ disabled={isLoading}
165
+ className="w-full py-4 rounded-full bg-accent-brown text-white font-serif tracking-wider shadow-lg hover:shadow-xl hover:bg-gold transform hover:-translate-y-0.5 transition-all duration-300 active:scale-95 flex items-center justify-center space-x-2 disabled:opacity-50"
166
+ >
167
+ {isLoading ? (
168
+ <span className="animate-spin">...</span>
169
+ ) : (
170
+ <span>登録する</span>
171
+ )}
172
+ </button>
173
+
174
+ <button
175
+ onClick={() => { setMode('select'); setError(''); }}
176
+ className="w-full py-2 text-soft-brown text-sm hover:text-accent-brown transition-colors"
177
+ >
178
+ 戻る
179
+ </button>
180
+ </div>
181
+ )}
182
+ </div>
183
+ </div>
184
+ </div>
185
+ );
186
+ };
187
+
188
+ // ======================================
189
+ // 2. ホーム画面(配信クイズ一覧)
190
+ // ======================================
191
+ const HomeScreen = ({ onStartQuiz, onShowRanking, onLogout }) => {
192
+ const [quizzes, setQuizzes] = useState([]);
193
+ const [profile, setProfile] = useState(null);
194
+ const [isLoading, setIsLoading] = useState(true);
195
+ const [error, setError] = useState('');
196
+
197
+ const user = SessionManager.getUser();
198
+
199
+ const subjectNames = {
200
+ jp: '国語',
201
+ math: '算数',
202
+ sci: '理科',
203
+ soc: '社会'
204
+ };
205
+
206
+ const subjectColors = {
207
+ jp: 'bg-subject-jp',
208
+ math: 'bg-subject-math',
209
+ sci: 'bg-subject-sci',
210
+ soc: 'bg-subject-soc'
211
+ };
212
+
213
+ useEffect(() => {
214
+ loadData();
215
+ }, []);
216
+
217
+ const loadData = async () => {
218
+ setIsLoading(true);
219
+ try {
220
+ // プロフィール取得
221
+ const profileResult = await ApiClient.getUserProfile(user.user_id);
222
+ if (profileResult.success) {
223
+ setProfile(profileResult.profile || profileResult.data);
224
+ }
225
+
226
+ // 今日のクイズ一覧取得
227
+ const quizzesResult = await ApiClient.getTodayQuizzes(user.user_id);
228
+ if (quizzesResult.success) {
229
+ setQuizzes(quizzesResult.quizzes || []);
230
+ }
231
+ } catch (e) {
232
+ setError('データの読み込みに失敗しました');
233
+ } finally {
234
+ setIsLoading(false);
235
+ }
236
+ };
237
+
238
+ const formatDate = () => {
239
+ const now = new Date();
240
+ const month = now.getMonth() + 1;
241
+ const day = now.getDate();
242
+ const weekdays = ['日', '月', '火', '水', '木', '金', '土'];
243
+ return `${month}月${day}日(${weekdays[now.getDay()]})`;
244
+ };
245
+
246
+ if (isLoading) {
247
+ return (
248
+ <div className="min-h-screen flex items-center justify-center">
249
+ <p className="text-soft-brown font-serif">読み込み中...</p>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ return (
255
+ <div className="min-h-screen p-6 fade-in relative overflow-hidden">
256
+ {/* 背景オーブ */}
257
+ <div className="orb w-64 h-64 bg-subject-jp top-20 -right-20 blur-3xl opacity-20"></div>
258
+ <div className="orb w-64 h-64 bg-subject-sci bottom-20 -left-20 blur-3xl opacity-20" style={{animationDelay: '-3s'}}></div>
259
+
260
+ <div className="max-w-md mx-auto">
261
+ {/* ヘッダー */}
262
+ <div className="text-center mb-8">
263
+ <p className="text-soft-brown text-sm font-serif">{formatDate()}</p>
264
+ <h1 className="text-2xl font-serif font-bold text-accent-brown mt-2">今日のクイズ</h1>
265
+ </div>
266
+
267
+ {/* ユーザー情報 */}
268
+ <div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-glass p-6 mb-6 border border-white/60">
269
+ <div className="flex items-center justify-between">
270
+ <div>
271
+ <p className="text-sm text-soft-brown">ようこそ</p>
272
+ <p className="text-lg font-bold text-accent-brown">{user.username || user.user_id}</p>
273
+ </div>
274
+ <div className="text-right">
275
+ <p className="text-sm text-soft-brown">累計ポイント</p>
276
+ <p className="text-2xl font-bold text-gold">{profile?.total_points || 0}</p>
277
+ </div>
278
+ </div>
279
+ </div>
280
+
281
+ {/* クイズ一覧 */}
282
+ <div className="space-y-4 mb-6">
283
+ {quizzes.length === 0 ? (
284
+ <div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-glass p-8 text-center border border-white/60">
285
+ <Icons.Book className="w-12 h-12 text-soft-brown mx-auto mb-4 opacity-50" />
286
+ <p className="text-soft-brown font-serif">今日の配信はまだありません</p>
287
+ <p className="text-xs text-soft-brown/60 mt-2">朝6:00と夕方15:00に配信されます</p>
288
+ </div>
289
+ ) : (
290
+ quizzes.map((quiz, index) => (
291
+ <div
292
+ key={quiz.quiz_id || index}
293
+ className="bg-white/80 backdrop-blur-md rounded-2xl shadow-glass p-6 border border-white/60"
294
+ >
295
+ <div className="flex items-center justify-between">
296
+ <div className="flex items-center space-x-4">
297
+ <div className={`w-12 h-12 rounded-full ${subjectColors[quiz.subject] || 'bg-gray-200'} flex items-center justify-center`}>
298
+ <span className="text-white font-bold text-lg">
299
+ {subjectNames[quiz.subject]?.charAt(0) || '?'}
300
+ </span>
301
+ </div>
302
+ <div>
303
+ <p className="font-bold text-accent-brown">{subjectNames[quiz.subject] || quiz.subject}</p>
304
+ <p className="text-xs text-soft-brown">{quiz.time_slot === 'morning' ? '朝の配信' : '夕方の配信'}</p>
305
+ </div>
306
+ </div>
307
+ <button
308
+ onClick={() => onStartQuiz(quiz.quiz_id)}
309
+ className="px-6 py-2 rounded-full bg-accent-brown text-white text-sm font-serif hover:bg-gold transition-all shadow-md hover:shadow-lg active:scale-95"
310
+ >
311
+ 挑戦する
312
+ </button>
313
+ </div>
314
+ </div>
315
+ ))
316
+ )}
317
+ </div>
318
+
319
+ {/* アクションボタン */}
320
+ <div className="space-y-3">
321
+ <button
322
+ onClick={onShowRanking}
323
+ className="w-full py-4 rounded-2xl bg-white/80 backdrop-blur-md border border-white/60 text-accent-brown font-serif shadow-glass hover:bg-white/90 transition-all"
324
+ >
325
+ ランキングを見る
326
+ </button>
327
+ <button
328
+ onClick={onLogout}
329
+ className="w-full py-3 text-soft-brown text-sm hover:text-accent-brown transition-colors"
330
+ >
331
+ ログアウト
332
+ </button>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ );
337
+ };
338
+
339
+ // ======================================
340
+ // 3. クイズ画面(4択UI)
341
+ // ======================================
342
+ const QuizScreen = ({ quizId, onFinish }) => {
343
+ const [questions, setQuestions] = useState([]);
344
+ const [currentIndex, setCurrentIndex] = useState(0);
345
+ const [answers, setAnswers] = useState([]);
346
+ const [timeRemaining, setTimeRemaining] = useState(180); // 3分
347
+ const [isLoading, setIsLoading] = useState(true);
348
+ const [isSubmitting, setIsSubmitting] = useState(false);
349
+ const [error, setError] = useState('');
350
+ const [quizInfo, setQuizInfo] = useState(null);
351
+
352
+ const user = SessionManager.getUser();
353
+ const timerRef = useRef(null);
354
+
355
+ const subjectNames = {
356
+ jp: '国語',
357
+ math: '算数',
358
+ sci: '理科',
359
+ soc: '社会'
360
+ };
361
+
362
+ useEffect(() => {
363
+ loadQuiz();
364
+ return () => {
365
+ if (timerRef.current) clearInterval(timerRef.current);
366
+ };
367
+ }, []);
368
+
369
+ const loadQuiz = async () => {
370
+ try {
371
+ const result = await ApiClient.getDeliveryQuiz(quizId, user.user_id);
372
+ if (result.success) {
373
+ setQuestions(result.questions || []);
374
+ setQuizInfo(result.quiz);
375
+ setAnswers(new Array(result.questions.length).fill(null));
376
+ startTimer();
377
+ } else {
378
+ setError(result.error?.message || '問題の取得に失敗しました');
379
+ }
380
+ } catch (e) {
381
+ setError('エラーが発生しました');
382
+ } finally {
383
+ setIsLoading(false);
384
+ }
385
+ };
386
+
387
+ const startTimer = () => {
388
+ timerRef.current = setInterval(() => {
389
+ setTimeRemaining(prev => {
390
+ if (prev <= 1) {
391
+ clearInterval(timerRef.current);
392
+ submitAllAnswers();
393
+ return 0;
394
+ }
395
+ return prev - 1;
396
+ });
397
+ }, 1000);
398
+ };
399
+
400
+ const handleSelectAnswer = (choiceIndex) => {
401
+ const question = questions[currentIndex];
402
+ const selectedAnswer = question.choices[choiceIndex];
403
+
404
+ const newAnswers = [...answers];
405
+ newAnswers[currentIndex] = {
406
+ question_id: question.id || question.ID,
407
+ subject: question.subject || question.SUBJECT,
408
+ category: question.category || question.CATEGORY,
409
+ user_answer: selectedAnswer,
410
+ correct_answer: question.answer || question.ANSWER,
411
+ is_correct: normalizeAnswer(selectedAnswer) === normalizeAnswer(question.answer || question.ANSWER)
412
+ };
413
+ setAnswers(newAnswers);
414
+
415
+ // 次の問題へ or 採点
416
+ setTimeout(() => {
417
+ if (currentIndex >= questions.length - 1) {
418
+ submitAllAnswers(newAnswers);
419
+ } else {
420
+ setCurrentIndex(currentIndex + 1);
421
+ }
422
+ }, 300);
423
+ };
424
+
425
+ const normalizeAnswer = (answer) => {
426
+ if (!answer) return '';
427
+ return answer.toString()
428
+ .toLowerCase()
429
+ .replace(/\s+/g, '')
430
+ .replace(/[0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
431
+ .replace(/[A-Za-z]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0));
432
+ };
433
+
434
+ const submitAllAnswers = async (finalAnswers = answers) => {
435
+ if (isSubmitting) return;
436
+ setIsSubmitting(true);
437
+
438
+ if (timerRef.current) clearInterval(timerRef.current);
439
+
440
+ try {
441
+ const result = await ApiClient.submitDeliveryAnswers(
442
+ user.user_id,
443
+ quizId,
444
+ finalAnswers.filter(a => a !== null),
445
+ timeRemaining
446
+ );
447
+
448
+ if (result.success) {
449
+ // 結果データをsessionStorageに保存
450
+ sessionStorage.setItem('quizResult', JSON.stringify({
451
+ result: result.result,
452
+ answers: finalAnswers,
453
+ questions: questions
454
+ }));
455
+ onFinish(result);
456
+ } else {
457
+ setError(result.error?.message || '採点に失敗しました');
458
+ }
459
+ } catch (e) {
460
+ setError('エラーが発生しました');
461
+ } finally {
462
+ setIsSubmitting(false);
463
+ }
464
+ };
465
+
466
+ const formatTime = (seconds) => {
467
+ const m = Math.floor(seconds / 60);
468
+ const s = seconds % 60;
469
+ return `${m}:${s.toString().padStart(2, '0')}`;
470
+ };
471
+
472
+ if (isLoading) {
473
+ return (
474
+ <div className="min-h-screen flex items-center justify-center">
475
+ <p className="text-soft-brown font-serif">問題を読み込み中...</p>
476
+ </div>
477
+ );
478
+ }
479
+
480
+ if (isSubmitting) {
481
+ return (
482
+ <div className="min-h-screen flex items-center justify-center">
483
+ <p className="text-soft-brown font-serif">採点中...</p>
484
+ </div>
485
+ );
486
+ }
487
+
488
+ if (error) {
489
+ return (
490
+ <div className="min-h-screen flex flex-col items-center justify-center p-6">
491
+ <p className="text-red-500 font-serif mb-4">{error}</p>
492
+ <button
493
+ onClick={() => window.location.reload()}
494
+ className="px-6 py-2 rounded-full bg-accent-brown text-white font-serif"
495
+ >
496
+ 再読み込み
497
+ </button>
498
+ </div>
499
+ );
500
+ }
501
+
502
+ const question = questions[currentIndex];
503
+
504
+ return (
505
+ <div className="min-h-screen p-6 fade-in relative overflow-hidden">
506
+ {/* ヘッダー */}
507
+ <div className="max-w-md mx-auto">
508
+ <div className="flex items-center justify-between mb-6">
509
+ <div className="flex items-center space-x-4">
510
+ <span className="px-4 py-1 rounded-full bg-white/80 text-accent-brown text-sm font-serif shadow-sm">
511
+ {subjectNames[quizInfo?.subject] || ''}
512
+ </span>
513
+ <span className="text-soft-brown text-sm">
514
+ {currentIndex + 1} / {questions.length}
515
+ </span>
516
+ </div>
517
+ <div className={`px-4 py-2 rounded-full font-bold text-white shadow-md ${timeRemaining <= 60 ? 'bg-red-500 animate-pulse' : 'bg-accent-brown'}`}>
518
+ {formatTime(timeRemaining)}
519
+ </div>
520
+ </div>
521
+
522
+ {/* 問題カード */}
523
+ <div className="bg-white/90 backdrop-blur-md rounded-3xl shadow-glass p-8 mb-6 border border-white/60">
524
+ <div className="mb-4">
525
+ <span className="px-3 py-1 rounded-full bg-subject-sci/20 text-subject-sci text-xs font-serif">
526
+ {question.difficulty || question.DIFFICULTY || '標準'}
527
+ </span>
528
+ </div>
529
+ <p className="text-lg text-accent-brown leading-relaxed font-serif">
530
+ {question.question || question.QUESTION}
531
+ </p>
532
+ </div>
533
+
534
+ {/* 選択肢 */}
535
+ <div className="space-y-3">
536
+ {(question.choices || []).map((choice, index) => (
537
+ <button
538
+ key={index}
539
+ onClick={() => handleSelectAnswer(index)}
540
+ className="w-full p-5 rounded-2xl bg-white/80 backdrop-blur-md border-2 border-white/60 text-left font-serif text-accent-brown shadow-glass hover:border-gold hover:bg-white transition-all active:scale-98"
541
+ >
542
+ <span className="inline-block w-8 h-8 rounded-full bg-soft-brown/20 text-center leading-8 mr-3 text-sm">
543
+ {['A', 'B', 'C', 'D'][index]}
544
+ </span>
545
+ {choice}
546
+ </button>
547
+ ))}
548
+ </div>
549
+ </div>
550
+ </div>
551
+ );
552
+ };
553
+
554
+ // ======================================
555
+ // 4. 結果画面
556
+ // ======================================
557
+ const ResultScreen = ({ onBackHome, onShowRanking }) => {
558
+ const [resultData, setResultData] = useState(null);
559
+
560
+ useEffect(() => {
561
+ const stored = sessionStorage.getItem('quizResult');
562
+ if (stored) {
563
+ setResultData(JSON.parse(stored));
564
+ }
565
+ }, []);
566
+
567
+ if (!resultData) {
568
+ return (
569
+ <div className="min-h-screen flex items-center justify-center">
570
+ <p className="text-soft-brown font-serif">結果データがありません</p>
571
+ </div>
572
+ );
573
+ }
574
+
575
+ const { result, answers, questions } = resultData;
576
+ const correctCount = answers.filter(a => a?.is_correct).length;
577
+ const totalCount = questions.length;
578
+ const percentage = Math.round((correctCount / totalCount) * 100);
579
+
580
+ return (
581
+ <div className="min-h-screen p-6 fade-in relative overflow-hidden">
582
+ <div className="orb w-64 h-64 bg-gold top-10 right-10 blur-3xl opacity-20"></div>
583
+
584
+ <div className="max-w-md mx-auto">
585
+ {/* スコア表示 */}
586
+ <div className="bg-white/90 backdrop-blur-md rounded-3xl shadow-glass p-8 mb-6 text-center border border-white/60">
587
+ <h1 className="text-2xl font-serif font-bold text-accent-brown mb-6">結果発表</h1>
588
+
589
+ <div className="mb-6">
590
+ <div className="text-6xl font-bold text-gold mb-2">
591
+ {correctCount}<span className="text-2xl text-soft-brown">/{totalCount}</span>
592
+ </div>
593
+ <p className="text-soft-brown font-serif">正解</p>
594
+ </div>
595
+
596
+ <div className="bg-base-beige rounded-2xl p-4 mb-6">
597
+ <div className="flex justify-between items-center">
598
+ <span className="text-soft-brown">獲得ポイント</span>
599
+ <span className="text-2xl font-bold text-gold">{result?.points_earned || 0} pt</span>
600
+ </div>
601
+ </div>
602
+
603
+ {/* 正解率バー */}
604
+ <div className="mb-4">
605
+ <div className="h-3 bg-gray-200 rounded-full overflow-hidden">
606
+ <div
607
+ className="h-full bg-gradient-to-r from-gold to-subject-soc rounded-full transition-all duration-1000"
608
+ style={{ width: `${percentage}%` }}
609
+ ></div>
610
+ </div>
611
+ <p className="text-sm text-soft-brown mt-2">正解率 {percentage}%</p>
612
+ </div>
613
+ </div>
614
+
615
+ {/* 回答詳細 */}
616
+ <div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-glass p-6 mb-6 border border-white/60">
617
+ <h2 className="text-lg font-serif font-bold text-accent-brown mb-4">回答詳細</h2>
618
+ <div className="space-y-3 max-h-64 overflow-y-auto">
619
+ {answers.map((answer, index) => (
620
+ <div
621
+ key={index}
622
+ className={`p-4 rounded-xl ${answer?.is_correct ? 'bg-green-50 border-l-4 border-green-400' : 'bg-red-50 border-l-4 border-red-400'}`}
623
+ >
624
+ <div className="flex items-center justify-between mb-2">
625
+ <span className="text-sm font-bold">問{index + 1}</span>
626
+ <span className={`text-sm ${answer?.is_correct ? 'text-green-600' : 'text-red-600'}`}>
627
+ {answer?.is_correct ? '正解' : '不正解'}
628
+ </span>
629
+ </div>
630
+ <p className="text-xs text-soft-brown mb-1">
631
+ あなたの回答: {answer?.user_answer || '未回答'}
632
+ </p>
633
+ {!answer?.is_correct && (
634
+ <p className="text-xs text-green-600">
635
+ 正解: {answer?.correct_answer}
636
+ </p>
637
+ )}
638
+ </div>
639
+ ))}
640
+ </div>
641
+ </div>
642
+
643
+ {/* アクションボタン */}
644
+ <div className="space-y-3">
645
+ <button
646
+ onClick={onShowRanking}
647
+ className="w-full py-4 rounded-2xl bg-accent-brown text-white font-serif shadow-lg hover:bg-gold transition-all"
648
+ >
649
+ ランキングを見る
650
+ </button>
651
+ <button
652
+ onClick={onBackHome}
653
+ className="w-full py-4 rounded-2xl bg-white/80 backdrop-blur-md border border-white/60 text-accent-brown font-serif shadow-glass hover:bg-white/90 transition-all"
654
+ >
655
+ ホームに戻る
656
+ </button>
657
+ </div>
658
+ </div>
659
+ </div>
660
+ );
661
+ };
662
+
663
+ // ======================================
664
+ // 5. ランキング画面
665
+ // ======================================
666
+ const RankingScreen = ({ onBack }) => {
667
+ const [rankings, setRankings] = useState([]);
668
+ const [selectedType, setSelectedType] = useState('total');
669
+ const [isLoading, setIsLoading] = useState(true);
670
+ const [myRank, setMyRank] = useState(null);
671
+
672
+ const user = SessionManager.getUser();
673
+
674
+ const types = [
675
+ { id: 'total', name: '総合' },
676
+ { id: 'jp', name: '国語' },
677
+ { id: 'math', name: '算数' },
678
+ { id: 'sci', name: '理科' },
679
+ { id: 'soc', name: '社会' }
680
+ ];
681
+
682
+ useEffect(() => {
683
+ loadRanking();
684
+ }, [selectedType]);
685
+
686
+ const loadRanking = async () => {
687
+ setIsLoading(true);
688
+ try {
689
+ const today = new Date().toISOString().split('T')[0];
690
+ const result = await ApiClient.getRanking(today, selectedType);
691
+ if (result.success) {
692
+ setRankings(result.rankings || []);
693
+ // 自分の順位を探す
694
+ const myData = result.rankings?.find(r => r.user_id === user.user_id);
695
+ setMyRank(myData);
696
+ }
697
+ } catch (e) {
698
+ console.error('ランキング取得エラー:', e);
699
+ } finally {
700
+ setIsLoading(false);
701
+ }
702
+ };
703
+
704
+ const getRankStyle = (rank) => {
705
+ if (rank === 1) return 'bg-yellow-100 border-yellow-400';
706
+ if (rank === 2) return 'bg-gray-100 border-gray-400';
707
+ if (rank === 3) return 'bg-orange-100 border-orange-400';
708
+ return 'bg-white border-transparent';
709
+ };
710
+
711
+ return (
712
+ <div className="min-h-screen p-6 fade-in relative overflow-hidden">
713
+ <div className="orb w-64 h-64 bg-gold top-20 -left-20 blur-3xl opacity-20"></div>
714
+
715
+ <div className="max-w-md mx-auto">
716
+ {/* ヘッダー */}
717
+ <div className="flex items-center justify-between mb-6">
718
+ <button
719
+ onClick={onBack}
720
+ className="p-2 rounded-full bg-white/80 text-accent-brown shadow-sm hover:bg-white transition-all"
721
+ >
722
+
723
+ </button>
724
+ <h1 className="text-xl font-serif font-bold text-accent-brown">ランキング</h1>
725
+ <div className="w-10"></div>
726
+ </div>
727
+
728
+ {/* タブ */}
729
+ <div className="flex space-x-2 mb-6 overflow-x-auto pb-2">
730
+ {types.map(type => (
731
+ <button
732
+ key={type.id}
733
+ onClick={() => setSelectedType(type.id)}
734
+ className={`px-4 py-2 rounded-full text-sm font-serif whitespace-nowrap transition-all ${
735
+ selectedType === type.id
736
+ ? 'bg-accent-brown text-white shadow-md'
737
+ : 'bg-white/80 text-soft-brown hover:bg-white'
738
+ }`}
739
+ >
740
+ {type.name}
741
+ </button>
742
+ ))}
743
+ </div>
744
+
745
+ {/* 自分の順位 */}
746
+ {myRank && (
747
+ <div className="bg-gold/20 backdrop-blur-md rounded-2xl p-6 mb-6 border border-gold/40">
748
+ <p className="text-sm text-accent-brown mb-1">あなたの順位</p>
749
+ <div className="flex items-baseline space-x-2">
750
+ <span className="text-4xl font-bold text-gold">{myRank.rank || '-'}</span>
751
+ <span className="text-soft-brown">位</span>
752
+ <span className="ml-auto text-lg font-bold text-accent-brown">{myRank.points || 0} pt</span>
753
+ </div>
754
+ </div>
755
+ )}
756
+
757
+ {/* ランキングリスト */}
758
+ <div className="bg-white/80 backdrop-blur-md rounded-2xl shadow-glass border border-white/60 overflow-hidden">
759
+ {isLoading ? (
760
+ <div className="p-8 text-center">
761
+ <p className="text-soft-brown">読み込み中...</p>
762
+ </div>
763
+ ) : rankings.length === 0 ? (
764
+ <div className="p-8 text-center">
765
+ <p className="text-soft-brown">まだデータがありません</p>
766
+ </div>
767
+ ) : (
768
+ <div className="divide-y divide-gray-100">
769
+ {rankings.slice(0, 20).map((item, index) => (
770
+ <div
771
+ key={item.user_id || index}
772
+ className={`flex items-center p-4 ${getRankStyle(item.rank)} ${item.user_id === user.user_id ? 'border-l-4 border-l-gold' : ''}`}
773
+ >
774
+ <div className="w-10 text-center font-bold text-accent-brown">
775
+ {item.rank}
776
+ </div>
777
+ <div className="flex-1 ml-4">
778
+ <p className="font-serif text-accent-brown">{item.username || item.user_id}</p>
779
+ </div>
780
+ <div className="text-right">
781
+ <p className="font-bold text-gold">{item.points} pt</p>
782
+ </div>
783
+ </div>
784
+ ))}
785
+ </div>
786
+ )}
787
+ </div>
788
+ </div>
789
+ </div>
790
+ );
791
+ };
792
+
793
+ // ======================================
794
+ // 6. メインアプリ
795
+ // ======================================
796
+ const App = () => {
797
+ const [screen, setScreen] = useState('login');
798
+ const [userName, setUserName] = useState('');
799
+ const [currentQuizId, setCurrentQuizId] = useState(null);
800
+
801
+ useEffect(() => {
802
+ // 既存ログインチェック
803
+ const user = SessionManager.getUser();
804
+ if (user) {
805
+ setUserName(user.username || user.user_id);
806
+ setScreen('home');
807
+ }
808
+
809
+ // URLパラメータからクイズIDを取得
810
+ const params = new URLSearchParams(window.location.search);
811
+ const quizId = params.get('d');
812
+ const token = params.get('t');
813
+
814
+ if (quizId) {
815
+ setCurrentQuizId(quizId);
816
+ if (user) {
817
+ setScreen('quiz');
818
+ }
819
+ }
820
+ }, []);
821
+
822
+ const handleStart = (name) => {
823
+ setUserName(name);
824
+ if (currentQuizId) {
825
+ setScreen('quiz');
826
+ } else {
827
+ setScreen('home');
828
+ }
829
+ };
830
+
831
+ const handleStartQuiz = (quizId) => {
832
+ setCurrentQuizId(quizId);
833
+ setScreen('quiz');
834
+ };
835
+
836
+ const handleQuizFinish = (result) => {
837
+ setScreen('result');
838
+ };
839
+
840
+ const handleLogout = () => {
841
+ SessionManager.clearAll();
842
+ setUserName('');
843
+ setScreen('login');
844
+ };
845
+
846
+ return (
847
+ <div className="min-h-screen bg-base-beige">
848
+ {screen === 'login' && (
849
+ <LoginScreen onStart={handleStart} />
850
+ )}
851
+ {screen === 'home' && (
852
+ <HomeScreen
853
+ onStartQuiz={handleStartQuiz}
854
+ onShowRanking={() => setScreen('ranking')}
855
+ onLogout={handleLogout}
856
+ />
857
+ )}
858
+ {screen === 'quiz' && currentQuizId && (
859
+ <QuizScreen
860
+ quizId={currentQuizId}
861
+ onFinish={handleQuizFinish}
862
+ />
863
+ )}
864
+ {screen === 'result' && (
865
+ <ResultScreen
866
+ onBackHome={() => { setCurrentQuizId(null); setScreen('home'); }}
867
+ onShowRanking={() => setScreen('ranking')}
868
+ />
869
+ )}
870
+ {screen === 'ranking' && (
871
+ <RankingScreen onBack={() => setScreen('home')} />
872
+ )}
873
+ </div>
874
+ );
875
+ };
js/config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ // --- API設定・定数 ---
2
+ const API_CONFIG = {
3
+ // GAS API URL(v2用デプロイメント @54)
4
+ BASE_URL: 'https://script.google.com/macros/s/AKfycbyo8CX6bDA4uP2sx-Jb1USt6l_615ACFYGpn4Bf_whpOZEhEKO6J8neHVjDunVGDnj8/exec',
5
+ TIMEOUT: 300000, // 300秒
6
+ USE_MOCK: false
7
+ };
js/icons.js ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // --- アイコンコンポーネント (SVG) ---
2
+ // 繊細な線画アイコンで高級感を出す
3
+ const Icons = {
4
+ Book: ({ className }) => (
5
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
6
+ <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
7
+ <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
8
+ </svg>
9
+ ),
10
+ Pen: ({ className }) => (
11
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
12
+ <path d="M12 19l7-7 3 3-7 7-3-3z"></path>
13
+ <path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"></path>
14
+ <path d="M2 2l7.586 7.586"></path>
15
+ <circle cx="11" cy="11" r="2"></circle>
16
+ </svg>
17
+ ),
18
+ Flask: ({ className }) => (
19
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
20
+ <path d="M10 2v7.31"></path>
21
+ <path d="M14 2v7.31"></path>
22
+ <path d="M8.5 2h7"></path>
23
+ <path d="M14 9.3a6.5 6.5 0 1 1-4 0l4 0"></path>
24
+ </svg>
25
+ ),
26
+ Globe: ({ className }) => (
27
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
28
+ <circle cx="12" cy="12" r="10"></circle>
29
+ <line x1="2" y1="12" x2="22" y2="12"></line>
30
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
31
+ </svg>
32
+ ),
33
+ User: ({ className }) => (
34
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
35
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
36
+ <circle cx="12" cy="7" r="4"></circle>
37
+ </svg>
38
+ ),
39
+ Clock: ({ className }) => (
40
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className={className}>
41
+ <circle cx="12" cy="12" r="10"></circle>
42
+ <polyline points="12 6 12 12 16 14"></polyline>
43
+ </svg>
44
+ ),
45
+ Check: ({ className }) => (
46
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
47
+ <polyline points="20 6 9 17 4 12"></polyline>
48
+ </svg>
49
+ ),
50
+ Sparkles: ({ className }) => (
51
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="none" className={className}>
52
+ <path d="M12 2L9.5 9.5 2 12l7.5 2.5L12 22l2.5-7.5L22 12l-7.5-2.5z"></path>
53
+ </svg>
54
+ ),
55
+ ArrowLeft: ({ className }) => (
56
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
57
+ <line x1="19" y1="12" x2="5" y2="12"></line>
58
+ <polyline points="12 19 5 12 12 5"></polyline>
59
+ </svg>
60
+ ),
61
+ X: ({ className }) => (
62
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
63
+ <line x1="18" y1="6" x2="6" y2="18"></line>
64
+ <line x1="6" y1="6" x2="18" y2="18"></line>
65
+ </svg>
66
+ )
67
+ };
js/quiz.js DELETED
@@ -1,145 +0,0 @@
1
- /**
2
- * 超天才クイズ v2.0.0 - クイズユーティリティ
3
- *
4
- * クイズ関連のヘルパー関数
5
- */
6
-
7
- const QuizUtils = {
8
- /**
9
- * 回答を正規化(比較用)
10
- *
11
- * @param {string} answer - 回答文字列
12
- * @returns {string} - 正規化された回答
13
- */
14
- normalizeAnswer(answer) {
15
- if (!answer) return '';
16
-
17
- return answer.toString()
18
- // 小文字に変換
19
- .toLowerCase()
20
- // 空白を除去
21
- .replace(/\s+/g, '')
22
- // 全角数字を半角に
23
- .replace(/[0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
24
- // 全角英字を半角に
25
- .replace(/[A-Za-z]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
26
- // 全角カッコを半角に
27
- .replace(/(/g, '(')
28
- .replace(/)/g, ')')
29
- // ハイフン統一
30
- .replace(/[ー-—]/g, '-');
31
- },
32
-
33
- /**
34
- * 回答が正解かどうかを判定
35
- *
36
- * @param {string} userAnswer - ユーザーの回答
37
- * @param {string} correctAnswer - 正解
38
- * @returns {boolean} - 正解かどうか
39
- */
40
- isCorrect(userAnswer, correctAnswer) {
41
- const normalizedUser = this.normalizeAnswer(userAnswer);
42
- const normalizedCorrect = this.normalizeAnswer(correctAnswer);
43
-
44
- // 完全一致
45
- if (normalizedUser === normalizedCorrect) {
46
- return true;
47
- }
48
-
49
- // 複数正解対応(「/」または「、」で区切られている場合)
50
- const alternatives = correctAnswer.split(/[\/、,]/).map(a => this.normalizeAnswer(a.trim()));
51
- if (alternatives.includes(normalizedUser)) {
52
- return true;
53
- }
54
-
55
- return false;
56
- },
57
-
58
- /**
59
- * ポイントを計算
60
- *
61
- * @param {number} correctCount - 正解数
62
- * @param {number} totalCount - 問題数
63
- * @param {number} timeRemaining - 残り時間(秒)
64
- * @returns {Object} - { correctPoints, timeBonus, totalPoints }
65
- */
66
- calculatePoints(correctCount, totalCount, timeRemaining) {
67
- const accuracy = totalCount > 0 ? correctCount / totalCount : 0;
68
- const correctPoints = correctCount * 100;
69
- const timeBonus = Math.floor((timeRemaining || 0) * accuracy);
70
- const totalPoints = correctPoints + timeBonus;
71
-
72
- return {
73
- correctPoints,
74
- timeBonus,
75
- totalPoints,
76
- accuracy: Math.round(accuracy * 100)
77
- };
78
- },
79
-
80
- /**
81
- * 時間を MM:SS 形式でフォーマット
82
- *
83
- * @param {number} seconds - 秒数
84
- * @returns {string} - フォーマットされた時間
85
- */
86
- formatTime(seconds) {
87
- const mins = Math.floor(seconds / 60);
88
- const secs = seconds % 60;
89
- return `${mins}:${secs.toString().padStart(2, '0')}`;
90
- },
91
-
92
- /**
93
- * 教科名を取得
94
- *
95
- * @param {string} subjectCode - 教科コード
96
- * @returns {string} - 教科名
97
- */
98
- getSubjectName(subjectCode) {
99
- const names = {
100
- jp: '国語',
101
- math: '算数',
102
- sci: '理科',
103
- soc: '社会'
104
- };
105
- return names[subjectCode] || subjectCode;
106
- },
107
-
108
- /**
109
- * クラス名を取得
110
- *
111
- * @param {number} level - クラスレベル(1-6)
112
- * @returns {string} - クラス名
113
- */
114
- getClassName(level) {
115
- const classes = {
116
- 1: '天才のたまご',
117
- 2: '天才の見習い',
118
- 3: '天才かも',
119
- 4: 'もうすぐ天才',
120
- 5: '天才',
121
- 6: '超天才'
122
- };
123
- return classes[level] || '天才のたまご';
124
- },
125
-
126
- /**
127
- * 難易度の色を取得
128
- *
129
- * @param {string} difficulty - 難易度
130
- * @returns {string} - CSSカラークラス
131
- */
132
- getDifficultyColor(difficulty) {
133
- const colors = {
134
- '基本': 'success',
135
- '標準': 'primary',
136
- '応用': 'warning'
137
- };
138
- return colors[difficulty] || 'primary';
139
- }
140
- };
141
-
142
- // デバッグ用
143
- if (typeof window !== 'undefined') {
144
- window.QuizUtils = QuizUtils;
145
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/sessionManager.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // --- セッション管理(localStorage) ---
2
+ const SessionManager = {
3
+ // ユーザー情報
4
+ setUser(user) {
5
+ console.log('[SessionManager] setUser:', user);
6
+ localStorage.setItem('quiz_user', JSON.stringify(user));
7
+ },
8
+ getUser() {
9
+ const data = localStorage.getItem('quiz_user');
10
+ const user = data ? JSON.parse(data) : null;
11
+ console.log('[SessionManager] getUser:', user);
12
+ return user;
13
+ },
14
+ clearUser() {
15
+ console.log('[SessionManager] clearUser');
16
+ localStorage.removeItem('quiz_user');
17
+ },
18
+
19
+ // セッション情報
20
+ setSession(session) {
21
+ console.log('[SessionManager] setSession:', session);
22
+ localStorage.setItem('quiz_session', JSON.stringify(session));
23
+ },
24
+ getSession() {
25
+ const data = localStorage.getItem('quiz_session');
26
+ const session = data ? JSON.parse(data) : null;
27
+ console.log('[SessionManager] getSession:', session);
28
+ return session;
29
+ },
30
+ clearSession() {
31
+ console.log('[SessionManager] clearSession');
32
+ localStorage.removeItem('quiz_session');
33
+ },
34
+
35
+ // 解答データ
36
+ setAnswers(answers) {
37
+ console.log('[SessionManager] setAnswers:', answers.length, 'answers');
38
+ localStorage.setItem('quiz_answers', JSON.stringify(answers));
39
+ },
40
+ getAnswers() {
41
+ const data = localStorage.getItem('quiz_answers');
42
+ const answers = data ? JSON.parse(data) : [];
43
+ console.log('[SessionManager] getAnswers:', answers.length, 'answers');
44
+ return answers;
45
+ },
46
+ addAnswer(answer) {
47
+ console.log('[SessionManager] addAnswer:', answer);
48
+ const answers = this.getAnswers();
49
+ answers.push(answer);
50
+ this.setAnswers(answers);
51
+ },
52
+ clearAnswers() {
53
+ console.log('[SessionManager] clearAnswers');
54
+ localStorage.removeItem('quiz_answers');
55
+ },
56
+
57
+ // 問題データ
58
+ setQuestions(questions) {
59
+ console.log('[SessionManager] setQuestions:', questions.length, 'questions');
60
+ localStorage.setItem('quiz_questions', JSON.stringify(questions));
61
+ },
62
+ getQuestions() {
63
+ const data = localStorage.getItem('quiz_questions');
64
+ const questions = data ? JSON.parse(data) : [];
65
+ console.log('[SessionManager] getQuestions:', questions.length, 'questions');
66
+ return questions;
67
+ },
68
+ clearQuestions() {
69
+ console.log('[SessionManager] clearQuestions');
70
+ localStorage.removeItem('quiz_questions');
71
+ },
72
+
73
+ // v1.4.0: クイズ要約(今回のクイズ内容)
74
+ setQuizSummary(summary) {
75
+ console.log('[SessionManager] setQuizSummary:', summary);
76
+ localStorage.setItem('quiz_summary', summary);
77
+ },
78
+ getQuizSummary() {
79
+ const summary = localStorage.getItem('quiz_summary');
80
+ console.log('[SessionManager] getQuizSummary:', summary);
81
+ return summary;
82
+ },
83
+ clearQuizSummary() {
84
+ console.log('[SessionManager] clearQuizSummary');
85
+ localStorage.removeItem('quiz_summary');
86
+ },
87
+
88
+ // 全クリア
89
+ clearAll() {
90
+ console.log('[SessionManager] clearAll');
91
+ this.clearUser();
92
+ this.clearSession();
93
+ this.clearAnswers();
94
+ this.clearQuestions();
95
+ this.clearQuizSummary();
96
+ }
97
+ };
quiz.html DELETED
@@ -1,344 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>クイズ - 超天才クイズ</title>
7
- <link rel="stylesheet" href="css/style.css">
8
- </head>
9
- <body>
10
- <div class="container">
11
- <header class="quiz-header">
12
- <div class="header-left">
13
- <span id="subject-name">国語</span>
14
- <span id="question-counter">1 / 10</span>
15
- </div>
16
- <div class="header-right">
17
- <div class="timer" id="timer">
18
- <span id="timer-display">3:00</span>
19
- </div>
20
- </div>
21
- </header>
22
-
23
- <main id="main-content">
24
- <!-- ローディング -->
25
- <section id="loading-section" class="card">
26
- <p>問題を読み込み中...</p>
27
- </section>
28
-
29
- <!-- クイズ画面 -->
30
- <section id="quiz-section" class="card hidden">
31
- <div class="question-area">
32
- <div class="difficulty-badge" id="difficulty">標準</div>
33
- <p class="question-text" id="question-text"></p>
34
- </div>
35
-
36
- <div class="answer-area">
37
- <!-- 4択選択肢 -->
38
- <div id="choices-container" class="choices-container">
39
- <button class="choice-btn" data-index="0"></button>
40
- <button class="choice-btn" data-index="1"></button>
41
- <button class="choice-btn" data-index="2"></button>
42
- <button class="choice-btn" data-index="3"></button>
43
- </div>
44
- <!-- フォールバック用テキスト入力(選択肢がない場合) -->
45
- <div id="text-input-container" class="hidden">
46
- <input type="text" id="answer-input" placeholder="答えを入力" autocomplete="off">
47
- <button id="submit-answer" class="btn-primary">回答する</button>
48
- </div>
49
- </div>
50
-
51
- </section>
52
-
53
- <!-- 期限切れ/エラー -->
54
- <section id="error-section" class="card hidden">
55
- <h2>エラー</h2>
56
- <p id="error-message"></p>
57
- <button onclick="location.href='index.html'" class="btn-primary">トップに戻る</button>
58
- </section>
59
- </main>
60
- </div>
61
-
62
- <script src="js/api.js"></script>
63
- <script src="js/auth.js"></script>
64
- <script src="js/quiz.js"></script>
65
- <script>
66
- // クイズ状態
67
- let quizState = {
68
- quizId: null,
69
- questions: [],
70
- answers: [],
71
- currentIndex: 0,
72
- startTime: null,
73
- timeRemaining: 180 // 3分 = 180秒
74
- };
75
-
76
- let timerInterval = null;
77
-
78
- // ページ読み込み時
79
- document.addEventListener('DOMContentLoaded', async () => {
80
- // URLパラメータからquiz_idを取得
81
- const params = new URLSearchParams(window.location.search);
82
- const quizId = params.get('d');
83
- const token = params.get('t');
84
-
85
- // トークンがあれば認証
86
- if (token) {
87
- await Auth.verifyAndSaveToken(token);
88
- }
89
-
90
- // ログインチェック
91
- const isLoggedIn = await Auth.checkLogin();
92
- if (!isLoggedIn) {
93
- window.location.href = 'index.html';
94
- return;
95
- }
96
-
97
- if (!quizId) {
98
- showError('配信IDが指定されていません');
99
- return;
100
- }
101
-
102
- quizState.quizId = quizId;
103
- await loadQuiz(quizId);
104
- });
105
-
106
- // クイズを読み込み
107
- async function loadQuiz(quizId) {
108
- try {
109
- const user = Auth.getUser();
110
- const result = await API.getDeliveryQuiz(quizId, user.id);
111
-
112
- if (!result.success) {
113
- showError(result.error || '問題の取得に失敗しました');
114
- return;
115
- }
116
-
117
- quizState.questions = result.questions;
118
- quizState.answers = new Array(result.questions.length).fill(null).map(() => ({
119
- answer: '',
120
- submitted: false
121
- }));
122
-
123
- // 教科名を設定
124
- const subjectNames = { jp: '国語', math: '算数', sci: '理科', soc: '社会' };
125
- document.getElementById('subject-name').textContent =
126
- subjectNames[result.quiz.subject] || result.quiz.subject;
127
-
128
- // クイズ画面を表示
129
- document.getElementById('loading-section').classList.add('hidden');
130
- document.getElementById('quiz-section').classList.remove('hidden');
131
-
132
- // タイマ���開始
133
- quizState.startTime = Date.now();
134
- startTimer();
135
-
136
- // 選択肢ボタンのイベント設定
137
- setupChoiceButtons();
138
-
139
- // 最初の問題を表示
140
- showQuestion(0);
141
-
142
- } catch (e) {
143
- showError('エラーが発生しました: ' + e.message);
144
- }
145
- }
146
-
147
- // 問題を表示
148
- function showQuestion(index) {
149
- if (index < 0 || index >= quizState.questions.length) return;
150
-
151
- quizState.currentIndex = index;
152
- const question = quizState.questions[index];
153
- const answer = quizState.answers[index];
154
-
155
- // 問題テキスト
156
- document.getElementById('question-text').textContent = question.question || question.QUESTION || '';
157
- document.getElementById('difficulty').textContent = question.difficulty || question.DIFFICULTY || '標準';
158
-
159
- // 選択肢がある場合は4択表示、なければテキスト入力
160
- const choices = question.choices || [];
161
- const choicesContainer = document.getElementById('choices-container');
162
- const textInputContainer = document.getElementById('text-input-container');
163
-
164
- if (choices.length >= 4) {
165
- // 4択モード
166
- choicesContainer.classList.remove('hidden');
167
- textInputContainer.classList.add('hidden');
168
-
169
- const buttons = choicesContainer.querySelectorAll('.choice-btn');
170
- buttons.forEach((btn, i) => {
171
- btn.textContent = choices[i] || '';
172
- btn.classList.remove('selected');
173
- // 既に回答済みならハイライト
174
- if (answer.answer === choices[i]) {
175
- btn.classList.add('selected');
176
- }
177
- });
178
- } else {
179
- // テキスト入力モード
180
- choicesContainer.classList.add('hidden');
181
- textInputContainer.classList.remove('hidden');
182
- const input = document.getElementById('answer-input');
183
- input.value = answer.answer || '';
184
- input.focus();
185
- }
186
-
187
- // カウンター更新
188
- document.getElementById('question-counter').textContent =
189
- `${index + 1} / ${quizState.questions.length}`;
190
- }
191
-
192
- // 選択肢クリック処理
193
- function setupChoiceButtons() {
194
- const buttons = document.querySelectorAll('.choice-btn');
195
- buttons.forEach(btn => {
196
- btn.addEventListener('click', () => {
197
- const selectedAnswer = btn.textContent;
198
- quizState.answers[quizState.currentIndex].answer = selectedAnswer;
199
-
200
- // 選択状態を更新
201
- buttons.forEach(b => b.classList.remove('selected'));
202
- btn.classList.add('selected');
203
-
204
- // 最終問題なら自動的に採点へ、それ以外は次の問題へ
205
- if (quizState.currentIndex >= quizState.questions.length - 1) {
206
- // 最終問題:選択後に自動で採点へ移行
207
- setTimeout(() => submitAllAnswers(), 300);
208
- } else {
209
- // 次の問題へ
210
- setTimeout(() => showQuestion(quizState.currentIndex + 1), 300);
211
- }
212
- });
213
- });
214
- }
215
-
216
- // タイマー開始
217
- function startTimer() {
218
- updateTimerDisplay();
219
-
220
- timerInterval = setInterval(() => {
221
- quizState.timeRemaining--;
222
- updateTimerDisplay();
223
-
224
- if (quizState.timeRemaining <= 0) {
225
- clearInterval(timerInterval);
226
- submitAllAnswers();
227
- }
228
- }, 1000);
229
- }
230
-
231
- // タイマー表示更新
232
- function updateTimerDisplay() {
233
- const minutes = Math.floor(quizState.timeRemaining / 60);
234
- const seconds = quizState.timeRemaining % 60;
235
- const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
236
- document.getElementById('timer-display').textContent = display;
237
-
238
- // 残り1分で警告色
239
- if (quizState.timeRemaining <= 60) {
240
- document.getElementById('timer').classList.add('warning');
241
- }
242
- }
243
-
244
- // 回答を保存
245
- function saveCurrentAnswer() {
246
- const input = document.getElementById('answer-input');
247
- quizState.answers[quizState.currentIndex].answer = input.value.trim();
248
- }
249
-
250
- // 全回答を送信
251
- async function submitAllAnswers() {
252
- clearInterval(timerInterval);
253
-
254
- document.getElementById('quiz-section').classList.add('hidden');
255
- document.getElementById('loading-section').classList.remove('hidden');
256
- document.getElementById('loading-section').innerHTML = '<p>採点中...</p>';
257
-
258
- try {
259
- const user = Auth.getUser();
260
-
261
- // 回答データを整形
262
- const answers = quizState.questions.map((q, i) => {
263
- const userAnswer = quizState.answers[i].answer;
264
- const correctAnswer = q.answer || q.ANSWER || '';
265
- const isCorrect = normalizeAnswer(userAnswer) === normalizeAnswer(correctAnswer);
266
-
267
- return {
268
- question_id: q.id || q.ID,
269
- subject: q.subject || q.SUBJECT,
270
- category: q.category || q.CATEGORY,
271
- user_answer: userAnswer,
272
- correct_answer: correctAnswer,
273
- is_correct: isCorrect,
274
- time_spent: 0
275
- };
276
- });
277
-
278
- const result = await API.submitDeliveryAnswers(
279
- user.id,
280
- quizState.quizId,
281
- answers,
282
- quizState.timeRemaining
283
- );
284
-
285
- if (result.success) {
286
- // 結果ページへ(sessionStorageでデータを渡す)
287
- sessionStorage.setItem('quizResult', JSON.stringify({
288
- result: result.result,
289
- answers: answers,
290
- questions: quizState.questions
291
- }));
292
- window.location.href = 'result.html';
293
- } else {
294
- showError(result.error || '採点に失敗しました');
295
- }
296
-
297
- } catch (e) {
298
- showError('エラーが発生しました: ' + e.message);
299
- }
300
- }
301
-
302
- // 回答を正規化
303
- function normalizeAnswer(answer) {
304
- if (!answer) return '';
305
- return answer.toString()
306
- .toLowerCase()
307
- .replace(/\s+/g, '')
308
- .replace(/[0-9]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0))
309
- .replace(/[A-Za-z]/g, s => String.fromCharCode(s.charCodeAt(0) - 0xFEE0));
310
- }
311
-
312
- // エラー表示
313
- function showError(message) {
314
- document.getElementById('loading-section').classList.add('hidden');
315
- document.getElementById('quiz-section').classList.add('hidden');
316
- document.getElementById('error-section').classList.remove('hidden');
317
- document.getElementById('error-message').textContent = message;
318
- }
319
-
320
- // イベントリスナー(テキスト入力モード用)
321
- document.getElementById('submit-answer').addEventListener('click', () => {
322
- saveCurrentAnswer();
323
- if (quizState.currentIndex >= quizState.questions.length - 1) {
324
- // 最終問題:自動で採点へ移行
325
- submitAllAnswers();
326
- } else {
327
- showQuestion(quizState.currentIndex + 1);
328
- }
329
- });
330
-
331
- document.getElementById('answer-input').addEventListener('keypress', (e) => {
332
- if (e.key === 'Enter') {
333
- saveCurrentAnswer();
334
- if (quizState.currentIndex >= quizState.questions.length - 1) {
335
- // 最終問題:自動で採点へ移行
336
- submitAllAnswers();
337
- } else {
338
- showQuestion(quizState.currentIndex + 1);
339
- }
340
- }
341
- });
342
- </script>
343
- </body>
344
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ranking.html DELETED
@@ -1,175 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>ランキング - 超天才クイズ</title>
7
- <link rel="stylesheet" href="css/style.css">
8
- </head>
9
- <body>
10
- <div class="container">
11
- <header>
12
- <h1>ランキング</h1>
13
- <p class="date" id="ranking-date"></p>
14
- </header>
15
-
16
- <main id="main-content">
17
- <!-- タブ -->
18
- <div class="tab-container">
19
- <button class="tab active" data-type="total">総合</button>
20
- <button class="tab" data-type="jp">国語</button>
21
- <button class="tab" data-type="math">算数</button>
22
- <button class="tab" data-type="sci">理科</button>
23
- <button class="tab" data-type="soc">社会</button>
24
- </div>
25
-
26
- <!-- ランキング表示 -->
27
- <section id="ranking-section" class="card">
28
- <div id="ranking-list">
29
- <p class="loading-text">ランキングを読み込み中...</p>
30
- </div>
31
- </section>
32
-
33
- <!-- 自分の順位 -->
34
- <section id="my-rank-section" class="card hidden">
35
- <h3>あなたの順位</h3>
36
- <div id="my-rank">
37
- <span class="rank-number" id="my-rank-number">-</span>
38
- <span class="rank-suffix">位</span>
39
- <span class="rank-points" id="my-rank-points">0pt</span>
40
- </div>
41
- </section>
42
-
43
- <!-- アクション -->
44
- <section class="actions">
45
- <button onclick="location.href='index.html'" class="btn-primary">
46
- トップに戻る
47
- </button>
48
- </section>
49
- </main>
50
-
51
- <footer>
52
- <p>&copy; 2025 超天才クイズ</p>
53
- </footer>
54
- </div>
55
-
56
- <script src="js/api.js"></script>
57
- <script src="js/auth.js"></script>
58
- <script>
59
- let currentType = 'total';
60
- let currentUser = null;
61
-
62
- // ページ読み込み時
63
- document.addEventListener('DOMContentLoaded', async () => {
64
- // 今日の日付を表示
65
- const today = new Date();
66
- const dateStr = today.toLocaleDateString('ja-JP', {
67
- year: 'numeric',
68
- month: 'long',
69
- day: 'numeric'
70
- });
71
- document.getElementById('ranking-date').textContent = dateStr;
72
-
73
- // ユーザー情報取得
74
- const isLoggedIn = await Auth.checkLogin();
75
- if (isLoggedIn) {
76
- currentUser = Auth.getUser();
77
- }
78
-
79
- // 初期ランキング読み込み
80
- loadRanking('total');
81
-
82
- // タブイベント
83
- document.querySelectorAll('.tab').forEach(tab => {
84
- tab.addEventListener('click', () => {
85
- document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
86
- tab.classList.add('active');
87
- loadRanking(tab.dataset.type);
88
- });
89
- });
90
- });
91
-
92
- // ランキング読み込み
93
- async function loadRanking(type) {
94
- currentType = type;
95
- const container = document.getElementById('ranking-list');
96
- container.innerHTML = '<p class="loading-text">読み込み中...</p>';
97
-
98
- try {
99
- const today = new Date().toISOString().split('T')[0];
100
- const result = await API.getRanking(today, type);
101
-
102
- if (!result.success) {
103
- container.innerHTML = '<p class="no-data">ランキングデータがありません</p>';
104
- return;
105
- }
106
-
107
- const rankings = result.rankings || [];
108
-
109
- if (rankings.length === 0) {
110
- container.innerHTML = '<p class="no-data">まだ誰も参加していません</p>';
111
- document.getElementById('my-rank-section').classList.add('hidden');
112
- return;
113
- }
114
-
115
- let html = '<ol class="ranking-list">';
116
- let myRank = null;
117
-
118
- rankings.forEach((item, index) => {
119
- const rank = item.rank || index + 1;
120
- const isMe = currentUser && item.user_id === currentUser.id;
121
-
122
- if (isMe) myRank = item;
123
-
124
- const rankClass = rank <= 3 ? `rank-${rank}` : '';
125
- const meClass = isMe ? 'is-me' : '';
126
-
127
- html += `
128
- <li class="ranking-item ${rankClass} ${meClass}">
129
- <span class="rank">${getRankDisplay(rank)}</span>
130
- <span class="name">${escapeHtml(item.display_name || '名無し')}</span>
131
- <span class="points">${item.points}pt</span>
132
- </li>
133
- `;
134
- });
135
-
136
- html += '</ol>';
137
- container.innerHTML = html;
138
-
139
- // 自分の順位を表示
140
- if (myRank) {
141
- document.getElementById('my-rank-section').classList.remove('hidden');
142
- document.getElementById('my-rank-number').textContent = myRank.rank;
143
- document.getElementById('my-rank-points').textContent = myRank.points + 'pt';
144
- } else if (currentUser) {
145
- document.getElementById('my-rank-section').classList.remove('hidden');
146
- document.getElementById('my-rank-number').textContent = '-';
147
- document.getElementById('my-rank-points').textContent = '未参加';
148
- } else {
149
- document.getElementById('my-rank-section').classList.add('hidden');
150
- }
151
-
152
- } catch (e) {
153
- console.error('Ranking error:', e);
154
- container.innerHTML = '<p class="error">ランキングの取得に失敗しました</p>';
155
- }
156
- }
157
-
158
- // 順位表示
159
- function getRankDisplay(rank) {
160
- if (rank === 1) return '🥇';
161
- if (rank === 2) return '🥈';
162
- if (rank === 3) return '🥉';
163
- return rank;
164
- }
165
-
166
- // HTMLエスケープ
167
- function escapeHtml(text) {
168
- if (!text) return '';
169
- const div = document.createElement('div');
170
- div.textContent = text;
171
- return div.innerHTML;
172
- }
173
- </script>
174
- </body>
175
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
result.html DELETED
@@ -1,151 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>結果 - 超天才クイズ</title>
7
- <link rel="stylesheet" href="css/style.css">
8
- </head>
9
- <body>
10
- <div class="container">
11
- <header>
12
- <h1>結果発表</h1>
13
- </header>
14
-
15
- <main id="main-content">
16
- <!-- 結果サマリー -->
17
- <section id="result-summary" class="card">
18
- <div class="score-display">
19
- <div class="score-circle">
20
- <span class="score-number" id="correct-count">0</span>
21
- <span class="score-divider">/</span>
22
- <span class="score-total" id="total-count">10</span>
23
- </div>
24
- <p class="score-label">正解</p>
25
- </div>
26
-
27
- <div class="points-breakdown">
28
- <div class="point-row">
29
- <span class="point-label">正答ポイント</span>
30
- <span class="point-value" id="correct-points">0</span>
31
- </div>
32
- <div class="point-row">
33
- <span class="point-label">時間ボーナス</span>
34
- <span class="point-value" id="time-bonus">0</span>
35
- </div>
36
- <div class="point-row total">
37
- <span class="point-label">合計ポイント</span>
38
- <span class="point-value" id="total-points">0</span>
39
- </div>
40
- </div>
41
- </section>
42
-
43
- <!-- 回答詳細 -->
44
- <section id="answer-details" class="card">
45
- <h2>回答詳細</h2>
46
- <div id="answer-list">
47
- <!-- 各問題の結果がここに表示される -->
48
- </div>
49
- </section>
50
-
51
- <!-- アクション -->
52
- <section class="actions">
53
- <button onclick="location.href='ranking.html'" class="btn-secondary">
54
- ランキングを見る
55
- </button>
56
- <button onclick="location.href='index.html'" class="btn-primary">
57
- トップに戻る
58
- </button>
59
- </section>
60
- </main>
61
-
62
- <footer>
63
- <p>&copy; 2025 超天才クイズ</p>
64
- </footer>
65
- </div>
66
-
67
- <script>
68
- // ページ読み込み時
69
- document.addEventListener('DOMContentLoaded', () => {
70
- // sessionStorageからデータを取得
71
- const dataStr = sessionStorage.getItem('quizResult');
72
-
73
- if (!dataStr) {
74
- document.getElementById('main-content').innerHTML =
75
- '<p class="error">結果データがありません</p>' +
76
- '<button onclick="location.href=\'index.html\'" class="btn-primary">トップに戻る</button>';
77
- return;
78
- }
79
-
80
- try {
81
- const data = JSON.parse(dataStr);
82
- displayResult(data);
83
- // 表示後にデータをクリア(リロード対策)
84
- // sessionStorage.removeItem('quizResult');
85
- } catch (e) {
86
- console.error('Parse error:', e);
87
- document.getElementById('main-content').innerHTML =
88
- '<p class="error">結果の読み込みに失敗しました</p>' +
89
- '<button onclick="location.href=\'index.html\'" class="btn-primary">トップに戻る</button>';
90
- }
91
- });
92
-
93
- // 結果を表示
94
- function displayResult(data) {
95
- const { result, answers, questions } = data;
96
-
97
- // サマリー
98
- document.getElementById('correct-count').textContent = result.correct_count;
99
- document.getElementById('total-count').textContent = result.total_count;
100
- document.getElementById('correct-points').textContent = result.correct_points + 'pt';
101
- document.getElementById('time-bonus').textContent = '+' + result.time_bonus + 'pt';
102
- document.getElementById('total-points').textContent = result.total_points + 'pt';
103
-
104
- // 回答詳細
105
- const listContainer = document.getElementById('answer-list');
106
- let html = '';
107
-
108
- answers.forEach((ans, i) => {
109
- const question = questions[i];
110
- const isCorrect = ans.is_correct;
111
- const statusClass = isCorrect ? 'correct' : 'incorrect';
112
- const statusIcon = isCorrect ? '○' : '×';
113
-
114
- html += `
115
- <div class="answer-item ${statusClass}">
116
- <div class="answer-header">
117
- <span class="status-icon">${statusIcon}</span>
118
- <span class="question-number">問${i + 1}</span>
119
- </div>
120
- <div class="answer-content">
121
- <p class="question-text">${escapeHtml(question.question || question.QUESTION || '')}</p>
122
- <div class="answer-comparison">
123
- <div class="your-answer">
124
- <span class="label">あなたの答え:</span>
125
- <span class="value">${escapeHtml(ans.user_answer) || '(未回答)'}</span>
126
- </div>
127
- ${!isCorrect ? `
128
- <div class="correct-answer">
129
- <span class="label">正解:</span>
130
- <span class="value">${escapeHtml(ans.correct_answer)}</span>
131
- </div>
132
- ` : ''}
133
- </div>
134
- </div>
135
- </div>
136
- `;
137
- });
138
-
139
- listContainer.innerHTML = html;
140
- }
141
-
142
- // HTMLエスケープ
143
- function escapeHtml(text) {
144
- if (!text) return '';
145
- const div = document.createElement('div');
146
- div.textContent = text;
147
- return div.innerHTML;
148
- }
149
- </script>
150
- </body>
151
- </html>