diff options
| author | polwex <polwex@sortug.com> | 2025-11-23 13:29:28 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-11-23 13:29:28 +0700 |
| commit | ba2dbc660c229d3e86662d35513dfa7c904d9870 (patch) | |
| tree | afdc039ac31587be0a3d089d024222fb2023fbe9 /packages/tweetdeck/src | |
| parent | cb1b56f5a0eddbf77446f415f2beda57c8305f85 (diff) | |
Diffstat (limited to 'packages/tweetdeck/src')
| -rw-r--r-- | packages/tweetdeck/src/App.tsx | 311 | ||||
| -rw-r--r-- | packages/tweetdeck/src/Test.tsx | 19 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/TweetCard.tsx | 2 | ||||
| -rw-r--r-- | packages/tweetdeck/src/index.ts | 3 | ||||
| -rw-r--r-- | packages/tweetdeck/src/pages/Deck.tsx | 310 |
5 files changed, 340 insertions, 305 deletions
diff --git a/packages/tweetdeck/src/App.tsx b/packages/tweetdeck/src/App.tsx index 924ff9a..44b6405 100644 --- a/packages/tweetdeck/src/App.tsx +++ b/packages/tweetdeck/src/App.tsx @@ -1,310 +1,15 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import "./styles/normalize.css"; import "./styles/index.css"; -import { Sidebar, type NewAccountInput } from "./components/Sidebar"; -import { ColumnBoard } from "./components/ColumnBoard"; -import { AddColumnModal } from "./components/AddColumnModal"; -import { usePersistentState } from "./hooks/usePersistentState"; -import type { - ColumnSnapshot, - ColumnState, - DeckAccount, - DeckColumn, - DeckListsCache, - FullscreenState, -} from "./types/app"; -import type { Tweet } from "./lib/fetching/types"; -import { generateId } from "./lib/utils/id"; -import { twitterClient } from "./lib/client/twitterClient"; -import { FullscreenColumn } from "./components/FullscreenColumn"; - -const ACCOUNTS_KEY = "tweetdeck.accounts"; -const COLUMNS_KEY = "tweetdeck.columns"; +import "./styles/normalize.css"; +import { Toaster } from "react-hot-toast"; +import Deck from "./pages/Deck"; +import Test from "./Test"; export function App() { - const [accounts, setAccounts] = usePersistentState<DeckAccount[]>( - ACCOUNTS_KEY, - [], - ); - const [columns, setColumns] = usePersistentState<DeckColumn[]>( - COLUMNS_KEY, - [], - ); - const [listsCache, setListsCache] = useState<DeckListsCache>({}); - const [activeAccountId, setActiveAccountId] = useState<string | undefined>( - () => accounts[0]?.id, - ); - const [isModalOpen, setModalOpen] = useState(false); - const [toast, setToast] = useState<string | null>(null); - const [fullscreen, setFullscreen] = useState<FullscreenState | null>(null); - const [columnSnapshots, setColumnSnapshots] = useState< - Record<string, ColumnSnapshot> - >({}); - - useEffect(() => { - if (!activeAccountId) { - const firstAccount = accounts[0]; - if (firstAccount) { - setActiveAccountId(firstAccount.id); - } - } - }, [accounts, activeAccountId]); - - useEffect(() => { - const acs = accounts.filter((a) => !a.avatar || !a.username); - console.log({ acs }); - const nacs = acs.map(async (acc) => { - const our = await twitterClient.own({ cookie: acc.cookie }); - const nacc = { - ...acc, - handle: our.username, - label: our.name, - avatar: our.avatar, - }; - return nacc; - }); - Promise.all(nacs) - .then((acs) => setAccounts(acs)) - .catch((err) => console.error(err)); - }, []); - - const handleAddAccount = useCallback( - (payload: NewAccountInput) => { - const label = `Session ${accounts.length + 1}`; - const account: DeckAccount = { - id: generateId(), - label, - accent: randomAccent(), - cookie: payload.cookie.trim(), - createdAt: Date.now(), - }; - setAccounts((prev) => [...prev, account]); - setActiveAccountId(account.id); - setToast(`${account.label} is ready`); - }, - [accounts.length, setAccounts], - ); - - const handleRemoveAccount = useCallback( - (accountId: string) => { - setAccounts((prev) => prev.filter((account) => account.id !== accountId)); - setColumns((prev) => - prev.filter((column) => column.accountId !== accountId), - ); - setListsCache((prev) => { - const next = { ...prev }; - delete next[accountId]; - return next; - }); - if (activeAccountId === accountId) { - setActiveAccountId(undefined); - } - }, - [activeAccountId, setAccounts, setColumns], - ); - - const handleAddColumn = useCallback( - (column: Omit<DeckColumn, "id">) => { - const nextColumn = { ...column, id: generateId() }; - setColumns((prev) => [...prev, nextColumn]); - setToast(`${nextColumn.title} added to deck`); - }, - [setColumns], - ); - - const handleRemoveColumn = useCallback( - (id: string) => { - setColumns((prev) => prev.filter((column) => column.id !== id)); - }, - [setColumns], - ); - - const fetchLists = useCallback( - async (accountId: string) => { - const account = accounts.find((acc) => acc.id === accountId); - if (!account) throw new Error("Account not found"); - if (listsCache[accountId]) return listsCache[accountId]; - console.log({ listsCache }); - const lists = await twitterClient.lists({ cookie: account.cookie }); - console.log({ lists }); - setListsCache((prev) => ({ ...prev, [accountId]: lists })); - return lists; - }, - [accounts, listsCache], - ); - - const handleColumnStateChange = useCallback( - (columnId: string, state: ColumnState) => { - setColumns((prev) => - prev.map((column) => - column.id === columnId ? { ...column, state } : column, - ), - ); - }, - [setColumns], - ); - - const handleColumnSnapshot = useCallback( - (columnId: string, snapshot: ColumnSnapshot) => { - setColumnSnapshots((prev) => { - const existing = prev[columnId]; - if ( - existing && - existing.tweets === snapshot.tweets && - existing.label === snapshot.label - ) { - return prev; - } - return { - ...prev, - [columnId]: { tweets: snapshot.tweets, label: snapshot.label }, - }; - }); - }, - [], - ); - - const openFullscreen = useCallback( - (payload: FullscreenState) => { - const snapshot = columnSnapshots[payload.column.id]; - setFullscreen({ - ...payload, - tweets: snapshot?.tweets ?? payload.tweets, - columnLabel: snapshot?.label ?? payload.columnLabel, - }); - }, - [columnSnapshots], - ); - - useEffect(() => { - if (!fullscreen) return; - const snapshot = columnSnapshots[fullscreen.column.id]; - if (!snapshot) return; - if ( - snapshot.tweets === fullscreen.tweets && - snapshot.label === fullscreen.columnLabel - ) { - return; - } - setFullscreen((prev) => { - if (!prev) return prev; - if (prev.column.id !== fullscreen.column.id) return prev; - const tweets = snapshot.tweets; - const index = Math.min(prev.index, Math.max(tweets.length - 1, 0)); - return { - ...prev, - tweets, - columnTitle: snapshot.label, - index, - }; - }); - }, [columnSnapshots, fullscreen]); - - const content = useMemo( - () => ( - <ColumnBoard - columns={columns} - accounts={accounts} - onRemove={handleRemoveColumn} - onStateChange={handleColumnStateChange} - onSnapshot={handleColumnSnapshot} - onEnterFullscreen={openFullscreen} - /> - ), - [ - accounts, - columns, - handleRemoveColumn, - handleColumnStateChange, - handleColumnSnapshot, - openFullscreen, - ], - ); - return ( - <div className="app-shell"> - <Sidebar - accounts={accounts} - activeAccountId={activeAccountId} - onActivate={(id) => setActiveAccountId(id)} - onAddAccount={handleAddAccount} - onRemoveAccount={handleRemoveAccount} - onAddColumn={() => setModalOpen(true)} - /> - - <main>{content}</main> - - <AddColumnModal - accounts={accounts} - activeAccountId={activeAccountId} - isOpen={isModalOpen} - onClose={() => setModalOpen(false)} - onAdd={handleAddColumn} - fetchLists={fetchLists} - listsCache={listsCache} - /> - - {toast && ( - <div className="toast" onAnimationEnd={() => setToast(null)}> - {toast} - </div> - )} - - {fullscreen && ( - <FullscreenColumn - state={fullscreen} - onExit={() => setFullscreen(null)} - onNavigate={(step) => { - setFullscreen((prev) => { - if (!prev) return prev; - if (!prev.tweets.length) return prev; - const nextIndex = Math.min( - prev.tweets.length - 1, - Math.max(0, prev.index + step), - ); - if (nextIndex === prev.index) return prev; - return { ...prev, index: nextIndex }; - }); - }} - hasPrevColumn={fullscreen.columnIndex > 0} - hasNextColumn={fullscreen.columnIndex < columns.length - 1} - onSwitchColumn={(direction) => { - setFullscreen((prev) => { - if (!prev) return prev; - const nextIndex = prev.columnIndex + direction; - if (nextIndex < 0) return prev; - if (nextIndex >= columns.length) { - setModalOpen(true); - return prev; - } - const nextColumn = columns[nextIndex]; - if (!nextColumn) return prev; - const snapshot = columnSnapshots[nextColumn.id]; - const account = accounts.find( - (acc) => acc.id === nextColumn.accountId, - ); - const tweets = snapshot?.tweets ?? []; - return { - column: nextColumn, - columnIndex: nextIndex, - columnLabel: snapshot?.label ?? nextColumn.title, - accent: account?.accent ?? prev.accent, - tweets, - index: 0, - }; - }); - }} - onAddColumn={() => setModalOpen(true)} - /> - )} - </div> + <> + <Test /> + <Toaster position="top-center" /> + </> ); } - -function randomAccent(): string { - const palette = ["#7f5af0", "#2cb67d", "#f25f4c", "#f0a500", "#19a7ce"]; - const pick = palette[Math.floor(Math.random() * palette.length)]; - return pick ?? "#7f5af0"; -} - export default App; diff --git a/packages/tweetdeck/src/Test.tsx b/packages/tweetdeck/src/Test.tsx new file mode 100644 index 0000000..28de9b9 --- /dev/null +++ b/packages/tweetdeck/src/Test.tsx @@ -0,0 +1,19 @@ +import "./styles/normalize.css"; +import "./styles/index.css"; +import { LangText } from "@sortug/prosody-ui"; +import toast from "react-hot-toast"; + +export function Test() { + const text = `อุตุฯ ฉบับ 16 เช็กจังหวัดภาคใต้เจอฝนตกหนักถึงหนักมาก`; + return ( + <div className="app-shell"> + <LangText + lang="th" + text={text} + theme="dark" + handleError={(e) => toast.error(e)} + /> + </div> + ); +} +export default Test; diff --git a/packages/tweetdeck/src/components/TweetCard.tsx b/packages/tweetdeck/src/components/TweetCard.tsx index 7cd2936..c9e6219 100644 --- a/packages/tweetdeck/src/components/TweetCard.tsx +++ b/packages/tweetdeck/src/components/TweetCard.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { Bookmark, Heart, Link2, MessageCircle, Repeat2 } from "lucide-react"; import type { Tweet } from "../lib/fetching/types"; import { timeAgo } from "../lib/utils/time"; -import { LangText } from "prosody-ui"; +import { LangText } from "@sortug/prosody-ui"; interface TweetCardProps { tweet: Tweet; diff --git a/packages/tweetdeck/src/index.ts b/packages/tweetdeck/src/index.ts index ccc86e7..9daa973 100644 --- a/packages/tweetdeck/src/index.ts +++ b/packages/tweetdeck/src/index.ts @@ -1,3 +1,4 @@ +import { handler } from "@sortug/sorlang-db"; import { serve } from "bun"; import index from "./index.html"; import { TwitterApiService } from "./lib/fetching/twitter-api"; @@ -39,7 +40,7 @@ const server = serve({ routes: { // Serve index.html for all unmatched routes. "/*": index, - + "/api/db": handler, "/api/hello": { async GET(req) { return Response.json({ diff --git a/packages/tweetdeck/src/pages/Deck.tsx b/packages/tweetdeck/src/pages/Deck.tsx new file mode 100644 index 0000000..c6fa41a --- /dev/null +++ b/packages/tweetdeck/src/pages/Deck.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import "../styles/normalize.css"; +import "../styles/index.css"; +import { Sidebar, type NewAccountInput } from "../components/Sidebar"; +import { ColumnBoard } from "../components/ColumnBoard"; +import { AddColumnModal } from "../components/AddColumnModal"; +import { usePersistentState } from "../hooks/usePersistentState"; +import type { + ColumnSnapshot, + ColumnState, + DeckAccount, + DeckColumn, + DeckListsCache, + FullscreenState, +} from "../types/app"; +import type { Tweet } from "../lib/fetching/types"; +import { generateId } from "../lib/utils/id"; +import { twitterClient } from "../lib/client/twitterClient"; +import { FullscreenColumn } from "../components/FullscreenColumn"; + +const ACCOUNTS_KEY = "tweetdeck.accounts"; +const COLUMNS_KEY = "tweetdeck.columns"; + +export function App() { + const [accounts, setAccounts] = usePersistentState<DeckAccount[]>( + ACCOUNTS_KEY, + [], + ); + const [columns, setColumns] = usePersistentState<DeckColumn[]>( + COLUMNS_KEY, + [], + ); + const [listsCache, setListsCache] = useState<DeckListsCache>({}); + const [activeAccountId, setActiveAccountId] = useState<string | undefined>( + () => accounts[0]?.id, + ); + const [isModalOpen, setModalOpen] = useState(false); + const [toast, setToast] = useState<string | null>(null); + const [fullscreen, setFullscreen] = useState<FullscreenState | null>(null); + const [columnSnapshots, setColumnSnapshots] = useState< + Record<string, ColumnSnapshot> + >({}); + + useEffect(() => { + if (!activeAccountId) { + const firstAccount = accounts[0]; + if (firstAccount) { + setActiveAccountId(firstAccount.id); + } + } + }, [accounts, activeAccountId]); + + useEffect(() => { + const acs = accounts.filter((a) => !a.avatar || !a.username); + console.log({ acs }); + const nacs = acs.map(async (acc) => { + const our = await twitterClient.own({ cookie: acc.cookie }); + const nacc = { + ...acc, + handle: our.username, + label: our.name, + avatar: our.avatar, + }; + return nacc; + }); + Promise.all(nacs) + .then((acs) => setAccounts(acs)) + .catch((err) => console.error(err)); + }, []); + + const handleAddAccount = useCallback( + (payload: NewAccountInput) => { + const label = `Session ${accounts.length + 1}`; + const account: DeckAccount = { + id: generateId(), + label, + accent: randomAccent(), + cookie: payload.cookie.trim(), + createdAt: Date.now(), + }; + setAccounts((prev) => [...prev, account]); + setActiveAccountId(account.id); + setToast(`${account.label} is ready`); + }, + [accounts.length, setAccounts], + ); + + const handleRemoveAccount = useCallback( + (accountId: string) => { + setAccounts((prev) => prev.filter((account) => account.id !== accountId)); + setColumns((prev) => + prev.filter((column) => column.accountId !== accountId), + ); + setListsCache((prev) => { + const next = { ...prev }; + delete next[accountId]; + return next; + }); + if (activeAccountId === accountId) { + setActiveAccountId(undefined); + } + }, + [activeAccountId, setAccounts, setColumns], + ); + + const handleAddColumn = useCallback( + (column: Omit<DeckColumn, "id">) => { + const nextColumn = { ...column, id: generateId() }; + setColumns((prev) => [...prev, nextColumn]); + setToast(`${nextColumn.title} added to deck`); + }, + [setColumns], + ); + + const handleRemoveColumn = useCallback( + (id: string) => { + setColumns((prev) => prev.filter((column) => column.id !== id)); + }, + [setColumns], + ); + + const fetchLists = useCallback( + async (accountId: string) => { + const account = accounts.find((acc) => acc.id === accountId); + if (!account) throw new Error("Account not found"); + if (listsCache[accountId]) return listsCache[accountId]; + console.log({ listsCache }); + const lists = await twitterClient.lists({ cookie: account.cookie }); + console.log({ lists }); + setListsCache((prev) => ({ ...prev, [accountId]: lists })); + return lists; + }, + [accounts, listsCache], + ); + + const handleColumnStateChange = useCallback( + (columnId: string, state: ColumnState) => { + setColumns((prev) => + prev.map((column) => + column.id === columnId ? { ...column, state } : column, + ), + ); + }, + [setColumns], + ); + + const handleColumnSnapshot = useCallback( + (columnId: string, snapshot: ColumnSnapshot) => { + setColumnSnapshots((prev) => { + const existing = prev[columnId]; + if ( + existing && + existing.tweets === snapshot.tweets && + existing.label === snapshot.label + ) { + return prev; + } + return { + ...prev, + [columnId]: { tweets: snapshot.tweets, label: snapshot.label }, + }; + }); + }, + [], + ); + + const openFullscreen = useCallback( + (payload: FullscreenState) => { + const snapshot = columnSnapshots[payload.column.id]; + setFullscreen({ + ...payload, + tweets: snapshot?.tweets ?? payload.tweets, + columnLabel: snapshot?.label ?? payload.columnLabel, + }); + }, + [columnSnapshots], + ); + + useEffect(() => { + if (!fullscreen) return; + const snapshot = columnSnapshots[fullscreen.column.id]; + if (!snapshot) return; + if ( + snapshot.tweets === fullscreen.tweets && + snapshot.label === fullscreen.columnLabel + ) { + return; + } + setFullscreen((prev) => { + if (!prev) return prev; + if (prev.column.id !== fullscreen.column.id) return prev; + const tweets = snapshot.tweets; + const index = Math.min(prev.index, Math.max(tweets.length - 1, 0)); + return { + ...prev, + tweets, + columnTitle: snapshot.label, + index, + }; + }); + }, [columnSnapshots, fullscreen]); + + const content = useMemo( + () => ( + <ColumnBoard + columns={columns} + accounts={accounts} + onRemove={handleRemoveColumn} + onStateChange={handleColumnStateChange} + onSnapshot={handleColumnSnapshot} + onEnterFullscreen={openFullscreen} + /> + ), + [ + accounts, + columns, + handleRemoveColumn, + handleColumnStateChange, + handleColumnSnapshot, + openFullscreen, + ], + ); + + return ( + <div className="app-shell"> + <Sidebar + accounts={accounts} + activeAccountId={activeAccountId} + onActivate={(id) => setActiveAccountId(id)} + onAddAccount={handleAddAccount} + onRemoveAccount={handleRemoveAccount} + onAddColumn={() => setModalOpen(true)} + /> + + <main>{content}</main> + + <AddColumnModal + accounts={accounts} + activeAccountId={activeAccountId} + isOpen={isModalOpen} + onClose={() => setModalOpen(false)} + onAdd={handleAddColumn} + fetchLists={fetchLists} + listsCache={listsCache} + /> + + {toast && ( + <div className="toast" onAnimationEnd={() => setToast(null)}> + {toast} + </div> + )} + + {fullscreen && ( + <FullscreenColumn + state={fullscreen} + onExit={() => setFullscreen(null)} + onNavigate={(step) => { + setFullscreen((prev) => { + if (!prev) return prev; + if (!prev.tweets.length) return prev; + const nextIndex = Math.min( + prev.tweets.length - 1, + Math.max(0, prev.index + step), + ); + if (nextIndex === prev.index) return prev; + return { ...prev, index: nextIndex }; + }); + }} + hasPrevColumn={fullscreen.columnIndex > 0} + hasNextColumn={fullscreen.columnIndex < columns.length - 1} + onSwitchColumn={(direction) => { + setFullscreen((prev) => { + if (!prev) return prev; + const nextIndex = prev.columnIndex + direction; + if (nextIndex < 0) return prev; + if (nextIndex >= columns.length) { + setModalOpen(true); + return prev; + } + const nextColumn = columns[nextIndex]; + if (!nextColumn) return prev; + const snapshot = columnSnapshots[nextColumn.id]; + const account = accounts.find( + (acc) => acc.id === nextColumn.accountId, + ); + const tweets = snapshot?.tweets ?? []; + return { + column: nextColumn, + columnIndex: nextIndex, + columnLabel: snapshot?.label ?? nextColumn.title, + accent: account?.accent ?? prev.accent, + tweets, + index: 0, + }; + }); + }} + onAddColumn={() => setModalOpen(true)} + /> + )} + </div> + ); +} + +function randomAccent(): string { + const palette = ["#7f5af0", "#2cb67d", "#f25f4c", "#f0a500", "#19a7ce"]; + const pick = palette[Math.floor(Math.random() * palette.length)]; + return pick ?? "#7f5af0"; +} + +export default App; |
