Spaces:
Sleeping
Sleeping
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 |
};
|