Tristan Yu commited on
Commit
a6b76d5
·
1 Parent(s): 89c18a4

UI: add sticky Hitokoto quote bar (categories d,h,i,k), next/hide, cached; include in Layout

Browse files
client/src/components/HitokotoBar.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ interface HitokotoItem {
4
+ id: number;
5
+ uuid: string;
6
+ hitokoto: string;
7
+ type: string; // a, b, c, ... categories
8
+ from: string;
9
+ from_who?: string | null;
10
+ }
11
+
12
+ const API_URL = 'https://v1.hitokoto.cn/?c=d&c=h&c=i&c=k&encode=json';
13
+
14
+ const LOCAL_HIDE_KEY = 'hitokoto_hide';
15
+ const LOCAL_LAST_KEY = 'hitokoto_last';
16
+ const LOCAL_LAST_AT = 'hitokoto_last_at';
17
+
18
+ const cacheMs = 5 * 60 * 1000; // 5 minutes
19
+
20
+ const HitokotoBar: React.FC = () => {
21
+ const [hidden, setHidden] = React.useState<boolean>(() => localStorage.getItem(LOCAL_HIDE_KEY) === '1');
22
+ const [loading, setLoading] = React.useState<boolean>(false);
23
+ const [error, setError] = React.useState<string>('');
24
+ const [quote, setQuote] = React.useState<HitokotoItem | null>(() => {
25
+ try {
26
+ const last = localStorage.getItem(LOCAL_LAST_KEY);
27
+ const at = Number(localStorage.getItem(LOCAL_LAST_AT) || '0');
28
+ if (last && Date.now() - at < cacheMs) {
29
+ return JSON.parse(last) as HitokotoItem;
30
+ }
31
+ } catch {}
32
+ return null;
33
+ });
34
+
35
+ const fetchQuote = async () => {
36
+ try {
37
+ setLoading(true);
38
+ setError('');
39
+ const res = await fetch(API_URL, { method: 'GET' });
40
+ if (!res.ok) throw new Error('Network error');
41
+ const data = await res.json();
42
+ setQuote(data as HitokotoItem);
43
+ try {
44
+ localStorage.setItem(LOCAL_LAST_KEY, JSON.stringify(data));
45
+ localStorage.setItem(LOCAL_LAST_AT, String(Date.now()));
46
+ } catch {}
47
+ } catch (e: any) {
48
+ setError('Failed to load quote');
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ React.useEffect(() => {
55
+ if (!hidden && !quote) {
56
+ fetchQuote();
57
+ }
58
+ }, [hidden]);
59
+
60
+ if (hidden) return null;
61
+
62
+ return (
63
+ <div className="fixed bottom-0 left-0 right-0 z-50">
64
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-3">
65
+ <div className="bg-white border border-gray-200 shadow-md rounded-lg px-4 py-2 flex items-center justify-between">
66
+ <div className="flex items-center min-w-0">
67
+ <span className="mr-2 text-indigo-600">💬</span>
68
+ <div className="min-w-0">
69
+ {loading ? (
70
+ <div className="text-sm text-gray-600">Loading…</div>
71
+ ) : error ? (
72
+ <div className="text-sm text-red-600">{error}</div>
73
+ ) : (
74
+ <div className="text-sm text-gray-800 truncate">
75
+ {quote ? `『${quote.hitokoto}』 — ${quote.from_who || quote.from || 'Hitokoto'}` : ' '}
76
+ </div>
77
+ )}
78
+ </div>
79
+ </div>
80
+ <div className="flex items-center space-x-2 flex-shrink-0">
81
+ <button
82
+ onClick={fetchQuote}
83
+ className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700"
84
+ title="Next quote"
85
+ >
86
+ Next
87
+ </button>
88
+ <button
89
+ onClick={() => { setHidden(true); localStorage.setItem(LOCAL_HIDE_KEY, '1'); }}
90
+ className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700"
91
+ title="Hide"
92
+ >
93
+ Hide
94
+ </button>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ };
101
+
102
+ export default HitokotoBar;
103
+
104
+
client/src/components/Layout.tsx CHANGED
@@ -9,6 +9,7 @@ import {
9
  UserIcon,
10
  ArrowRightOnRectangleIcon
11
  } from '@heroicons/react/24/outline';
 
12
 
13
  interface User {
14
  name: string;
@@ -186,6 +187,7 @@ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
186
  </div>
187
  </div>
188
  )}
 
189
  </div>
190
  );
191
  };
 
9
  UserIcon,
10
  ArrowRightOnRectangleIcon
11
  } from '@heroicons/react/24/outline';
12
+ import HitokotoBar from './HitokotoBar';
13
 
14
  interface User {
15
  name: string;
 
187
  </div>
188
  </div>
189
  )}
190
+ <HitokotoBar />
191
  </div>
192
  );
193
  };