feat: sync user action

This commit is contained in:
Ou 2024-10-15 12:05:03 +08:00
parent 051c827ac1
commit e8a2a7e6f0
23 changed files with 260 additions and 180 deletions

View File

@ -53,7 +53,8 @@
"overlayscrollbars-react": "^0.5.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-use": "^17.5.1"
"react-use": "^17.5.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "^1.14.3",

3
pnpm-lock.yaml generated
View File

@ -108,6 +108,9 @@ importers:
react-use:
specifier: ^17.5.1
version: 17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies:
'@eslint-react/eslint-plugin':
specifier: ^1.14.3

View File

@ -1,6 +0,0 @@
DROP TABLE IF EXISTS cache;
CREATE TABLE IF NOT EXISTS cache (
id TEXT PRIMARY KEY,
updated INTEGER,
data TEXT
);

View File

@ -29,7 +29,7 @@ export class UserTable {
.run(id, email, "", type, now, now)
logger.success(`add user ${id}`)
} else if (u.email !== email && u.type !== type) {
await this.db.prepare(`REPLACE INTO user (id, email, updated) VALUES (?, ?, ?)`).run(id, email, now)
await this.db.prepare(`UPDATE user SET email = ?, updated = ? WHERE id = ?`).run(id, email, now)
logger.success(`update user ${id} email`)
} else {
logger.info(`user ${id} already exists`)
@ -40,20 +40,19 @@ export class UserTable {
return (await this.db.prepare(`SELECT id, email, data, created, updated FROM user WHERE id = ?`).get(id)) as UserInfo
}
async setData(key: string, value: string) {
const now = Date.now()
async setData(key: string, value: string, updatedTime = Date.now()) {
const state = await this.db.prepare(
`REPLACE INTO user (id, data, updated) VALUES (?, ?, ?)`,
).run(key, JSON.stringify(value), now)
`UPDATE user SET data = ?, updated = ? WHERE id = ?`,
).run(value, updatedTime, key)
if (!state.success) throw new Error(`set user ${key} data failed`)
logger.success(`set ${key} cache`)
logger.success(`set ${key} data`)
}
async getData(id: string) {
const row: any = await this.db.prepare(`SELECT data, update FROM user WHERE id = ?`).get(id)
if (!row || !row.data) throw new Error(`user ${id} not found`)
const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id)
if (!row) throw new Error(`user ${id} not found`)
logger.success(`get ${id} data`)
return row.data as {
return row as {
data: string
updated: number
}

View File

@ -2,10 +2,11 @@ import process from "node:process"
import { jwtVerify } from "jose"
export default defineEventHandler(async (event) => {
const url = getRequestURL(event)
if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) {
event.context.disabledLogin = true
if (url.pathname.startsWith("/me")) throw createError({ statusCode: 506, message: "Server not configured" })
} else {
const url = getRequestURL(event)
if (/^\/(?:me|s)\//.test(url.pathname)) {
const token = getHeader(event, "Authorization")
if (token && process.env.JWT_SECRET) {
@ -18,7 +19,8 @@ export default defineEventHandler(async (event) => {
}
}
} catch {
logger.error("JWT verification failed")
if (url.pathname.startsWith("/me")) throw createError({ statusCode: 401, message: "JWT verification failed" })
logger.warn("JWT verification failed")
}
}
}

View File

@ -0,0 +1,5 @@
export default defineEventHandler(() => {
return {
hello: "world",
}
})

33
server/routes/me/sync.ts Normal file
View File

@ -0,0 +1,33 @@
import { verifyPrimitiveMetadata } from "@shared/verify"
import { UserTable } from "#/database/user"
export default defineEventHandler(async (event) => {
try {
const { id } = event.context.user
const db = useDatabase()
if (!db) throw new Error("Not found database")
const userTable = new UserTable(db)
if (event.method === "GET") {
const { data, updated } = await userTable.getData(id)
return {
data: data ? JSON.parse(data) : undefined,
updatedTime: updated,
}
} else if (event.method === "POST") {
const body = await readBody(event)
verifyPrimitiveMetadata(body)
const { updatedTime, data } = body
await userTable.setData(id, JSON.stringify(data), updatedTime)
return {
success: true,
updatedTime,
}
}
} catch (e) {
logger.error(e)
throw createError({
statusCode: 500,
message: e instanceof Error ? e.message : "Internal Server Error",
})
}
})

View File

@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => {
},
})
console.log(userInfo)
const userID = String(userInfo.id)
await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github")

View File

@ -13,7 +13,7 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
const isValid = (id: SourceID) => !id || !sources[id] || !sourcesFn[id]
if (isValid(id)) {
const redirectID = sources[id].redirect
const redirectID = sources?.[id].redirect
if (redirectID) id = redirectID
if (isValid(id)) throw new Error("Invalid source id")
}
@ -31,10 +31,8 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
if (now - cache.updated < interval) {
return {
status: "success",
data: {
updatedTime: now,
items: cache.data,
},
}
}
@ -50,10 +48,8 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
if (event.context.disabledLogin) {
return {
status: "cache",
data: {
updatedTime: cache.updated,
items: cache.data,
},
}
}
}
@ -66,16 +62,14 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
if (cacheTable) event.waitUntil(cacheTable.set(id, data))
return {
status: "success",
data: {
updatedTime: now,
items: data,
},
}
} catch (e: any) {
logger.error(e)
return {
status: "error",
message: e.message ?? e,
}
throw createError({
statusCode: 500,
message: e instanceof Error ? e.message : "Internal Server Error",
})
}
})

View File

@ -18,6 +18,12 @@ export type SourceID = {
export type ColumnID = (typeof columnIds)[number]
export type Metadata = Record<ColumnID, Column>
export interface PrimitiveMetadata {
updatedTime: number
data: Record<ColumnID, SourceID[]>
action: "init" | "manual" | "sync"
}
export interface OriginSource {
name: string
title?: string
@ -63,16 +69,8 @@ export interface NewsItem {
extra?: Record<string, any>
}
// 路由数据
export interface SourceInfo {
export interface SourceResponse {
status: "success" | "cache"
updatedTime: number | string
items: NewsItem[]
}
export type SourceResponse = {
status: "success" | "cache"
data: SourceInfo
} | {
status: "error"
message?: string
}

8
shared/verify.ts Normal file
View File

@ -0,0 +1,8 @@
import z from "zod"
export function verifyPrimitiveMetadata(target: any) {
return z.object({
data: z.record(z.string(), z.array(z.string())),
updatedTime: z.number(),
}).parse(target)
}

View File

@ -1,67 +0,0 @@
import { metadata } from "@shared/metadata"
import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "@shared/type.util"
import type { ColumnID, SourceID } from "@shared/types"
import type { PrimitiveAtom } from "jotai"
import { atom } from "jotai"
import { ofetch } from "ofetch"
function sync(nextValue: any) {
if (__ENABLE_LOGIN__) {
const jwt = localStorage.getItem("user_jwt")
if (jwt) {
ofetch("/me/sync", {
method: "POST",
body: { data: nextValue },
headers: {
Authorization: `Bearer ${localStorage.getItem("user_jwt")}`,
},
}).then(console.log).catch(e => console.error(e))
}
}
}
export function atomWithLocalStorage<T>(
key: string,
initialValue: T | (() => T),
initFn?: ((stored: T) => T),
storage?: (next: T) => void,
): PrimitiveAtom<T> {
const getInitialValue = () => {
const item = localStorage.getItem(key)
try {
if (item !== null) {
const stored = JSON.parse(item)
if (initFn) return initFn(stored)
return stored
}
} catch {
//
}
if (initialValue instanceof Function) return initialValue()
else return initialValue
}
const baseAtom = atom(getInitialValue())
const derivedAtom = atom(
get => get(baseAtom),
(get, set, update: any) => {
const nextValue = typeof update === "function" ? update(get(baseAtom)) : update
set(baseAtom, nextValue)
localStorage.setItem(key, JSON.stringify(nextValue))
storage?.(nextValue)
},
)
return derivedAtom
}
const initialSources = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata).map(([id, val]) => [id, val.sources]))
export const localSourcesAtom = atomWithLocalStorage<Record<ColumnID, SourceID[]>>("localsources", initialSources, (stored) => {
return typeSafeObjectFromEntries(typeSafeObjectEntries({
...initialSources,
...stored,
}).filter(([id]) => initialSources[id]).map(([id, val]) => {
if (id === "focus") return [id, val]
const oldS = val.filter(k => initialSources[id].includes(k))
const newS = initialSources[id].filter(k => !oldS.includes(k))
return [id, [...oldS, ...newS]]
}))
}, sync)

View File

@ -1,32 +1,22 @@
import { atom } from "jotai"
import type { ColumnID, SourceID } from "@shared/types"
import { metadata } from "@shared/metadata"
import { sources } from "@shared/sources"
import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "@shared/type.util"
import { atomWithLocalStorage } from "./atomWithLocalStorage"
import { primitiveMetadataAtom } from "./primitiveMetadataAtom"
import type { Update } from "./types"
const initialSources = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata).map(([id, val]) => [id, val.sources]))
export const localSourcesAtom = atomWithLocalStorage<Record<ColumnID, SourceID[]>>("localsources", () => {
return initialSources
}, (stored) => {
return typeSafeObjectFromEntries(typeSafeObjectEntries({
...initialSources,
...stored,
}).filter(([id]) => initialSources[id]).map(([id, val]) => {
if (id === "focus") return [id, val]
const oldS = val.filter(k => initialSources[id].includes(k))
const newS = initialSources[id].filter(k => !oldS.includes(k))
return [id, [...oldS, ...newS]]
}))
})
export { primitiveMetadataAtom, preprocessMetadata } from "./primitiveMetadataAtom"
export const focusSourcesAtom = atom((get) => {
return get(localSourcesAtom).focus
return get(primitiveMetadataAtom).data.focus
}, (get, set, update: Update<SourceID[]>) => {
const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update
set(localSourcesAtom, {
...get(localSourcesAtom),
set(primitiveMetadataAtom, {
updatedTime: Date.now(),
action: "manual",
data: {
...get(primitiveMetadataAtom).data,
focus: _,
},
})
})
@ -47,19 +37,21 @@ export const refetchSourcesAtom = atom(initRefetchSources())
export const currentColumnIDAtom = atom<ColumnID>("focus")
export const currentColumnAtom = atom((get) => {
export const currentSourcesAtom = atom((get) => {
const id = get(currentColumnIDAtom)
return get(localSourcesAtom)[id]
return get(primitiveMetadataAtom).data[id]
}, (get, set, update: Update<SourceID[]>) => {
const _ = update instanceof Function ? update(get(currentColumnAtom)) : update
set(localSourcesAtom, {
...get(localSourcesAtom),
const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update
set(primitiveMetadataAtom, {
updatedTime: Date.now(),
action: "manual",
data: {
...get(primitiveMetadataAtom).data,
[get(currentColumnIDAtom)]: _,
},
})
})
export type Update<T> = T | ((prev: T) => T)
export const goToTopAtom = atom({
ok: false,
fn: undefined as (() => void) | undefined,

View File

@ -0,0 +1,62 @@
import { metadata } from "@shared/metadata"
import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "@shared/type.util"
import type { PrimitiveAtom } from "jotai"
import { atom } from "jotai"
import type { PrimitiveMetadata } from "@shared/types"
import { verifyPrimitiveMetadata } from "@shared/verify"
import type { Update } from "./types"
function createPrimitiveMetadataAtom(
key: string,
initialValue: PrimitiveMetadata,
preprocess: ((stored: PrimitiveMetadata) => PrimitiveMetadata),
): PrimitiveAtom<PrimitiveMetadata> {
const getInitialValue = (): PrimitiveMetadata => {
const item = localStorage.getItem(key)
try {
if (item) {
const stored = JSON.parse(item) as PrimitiveMetadata
verifyPrimitiveMetadata(stored)
return preprocess(stored)
}
} catch { }
return initialValue
}
const baseAtom = atom(getInitialValue())
const derivedAtom = atom(
get => get(baseAtom),
(get, set, update: Update<PrimitiveMetadata>) => {
const nextValue = update instanceof Function ? update(get(baseAtom)) : update
set(baseAtom, nextValue)
localStorage.setItem(key, JSON.stringify(nextValue))
},
)
return derivedAtom
}
const initialMetadata = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata).map(([id, val]) => [id, val.sources]))
export function preprocessMetadata(target: PrimitiveMetadata, action: PrimitiveMetadata["action"] = "init") {
return {
data: {
...initialMetadata,
...typeSafeObjectFromEntries(
typeSafeObjectEntries(target.data)
.filter(([id]) => initialMetadata[id])
.map(([id, sources]) => {
if (id === "focus") return [id, sources]
const oldS = sources.filter(k => initialMetadata[id].includes(k))
const newS = initialMetadata[id].filter(k => !oldS.includes(k))
return [id, [...oldS, ...newS]]
}),
),
},
action,
updatedTime: target.updatedTime,
} as PrimitiveMetadata
}
export const primitiveMetadataAtom = createPrimitiveMetadataAtom("metadata", {
updatedTime: 0,
data: initialMetadata,
action: "init",
}, preprocessMetadata)

1
src/atoms/types.ts Normal file
View File

@ -0,0 +1 @@
export type Update<T> = T | ((prev: T) => T)

View File

@ -1,4 +1,4 @@
import type { NewsItem, SourceID, SourceInfo, SourceResponse } from "@shared/types"
import type { NewsItem, SourceID, SourceResponse } from "@shared/types"
import type { UseQueryResult } from "@tanstack/react-query"
import { useQuery } from "@tanstack/react-query"
import clsx from "clsx"
@ -28,7 +28,7 @@ interface NewsCardProps {
}
interface Query {
query: UseQueryResult<SourceInfo, Error>
query: UseQueryResult<SourceResponse, Error>
}
export const CardWrapper = forwardRef<HTMLDivElement, ItemsProps>(({ id, isDragged, handleListeners, style, ...props }, dndRef) => {
@ -111,11 +111,7 @@ function NewsCard({ id, inView, handleListeners }: NewsCardProps) {
Authorization: `Bearer ${localStorage.getItem("user_jwt")}`,
},
})
if (response.status === "error") {
throw new Error(response.message)
} else {
return response.data
}
return response
},
// refetch 时显示原有的数据
placeholderData: prev => prev,

View File

@ -17,10 +17,10 @@ import { CSS } from "@dnd-kit/utilities"
import { motion } from "framer-motion"
import type { ItemsProps } from "./card"
import { CardOverlay, CardWrapper } from "./card"
import { currentColumnAtom } from "~/atoms"
import { currentSourcesAtom } from "~/atoms"
export function Dnd() {
const [items, setItems] = useAtom(currentColumnAtom)
const [items, setItems] = useAtom(currentSourcesAtom)
return (
<DndWrapper items={items} setItems={setItems}>
<motion.div

View File

@ -7,7 +7,7 @@ import type { SourceID } from "@shared/types"
import { Homepage, Version } from "@shared/consts"
import { useLocalStorage } from "react-use"
import { useDark } from "~/hooks/useDark"
import { currentColumnAtom, goToTopAtom, refetchSourcesAtom } from "~/atoms"
import { currentSourcesAtom, goToTopAtom, refetchSourcesAtom } from "~/atoms"
function ThemeToggle() {
const { toggleDark } = useDark()
@ -75,19 +75,19 @@ export function GithubIcon() {
}
function RefreshButton() {
const currentColumn = useAtomValue(currentColumnAtom)
const currentSources = useAtomValue(currentSourcesAtom)
const setRefetchSource = useSetAtom(refetchSourcesAtom)
const refreshAll = useCallback(() => {
const obj = Object.fromEntries(currentColumn.map(id => [id, Date.now()]))
const obj = Object.fromEntries(currentSources.map(id => [id, Date.now()]))
setRefetchSource(prev => ({
...prev,
...obj,
}))
}, [currentColumn, setRefetchSource])
}, [currentSources, setRefetchSource])
const isFetching = useIsFetching({
predicate: (query) => {
return currentColumn.includes(query.queryKey[0] as SourceID)
return currentSources.includes(query.queryKey[0] as SourceID)
},
})

57
src/hooks/useSync.ts Normal file
View File

@ -0,0 +1,57 @@
import type { PrimitiveMetadata } from "@shared/types"
import { useAtom } from "jotai"
import { ofetch } from "ofetch"
import { useCallback, useEffect } from "react"
import { preprocessMetadata, primitiveMetadataAtom } from "~/atoms"
export function useSync() {
const [primitiveMetadata, setPrimitiveMetadata] = useAtom(primitiveMetadataAtom)
const uploadMetadata = useCallback(async () => {
if (!__ENABLE_LOGIN__) return
const jwt = localStorage.getItem("user_jwt")
if (!jwt) return
if (primitiveMetadata.action !== "manual") return
try {
await ofetch("/api/me/sync", {
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
},
body: {
data: primitiveMetadata.data,
updatedTime: primitiveMetadata.updatedTime,
},
})
} catch (e) {
console.error(e)
}
}, [primitiveMetadata])
const downloadMetadata = useCallback(async () => {
if (!__ENABLE_LOGIN__) return
const jwt = localStorage.getItem("user_jwt")
if (!jwt) return
try {
const { data, updatedTime } = await ofetch("/api/me/sync", {
headers: {
Authorization: `Bearer ${jwt}`,
},
}) as PrimitiveMetadata
if (data && updatedTime > primitiveMetadata.updatedTime) {
// 不用同步 action 字段
setPrimitiveMetadata(preprocessMetadata({ action: "sync", data, updatedTime }, "sync"))
}
} catch (e) {
console.error(e)
}
// 只需要在初始化时执行一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setPrimitiveMetadata])
useEffect(() => {
downloadMetadata()
}, [setPrimitiveMetadata, downloadMetadata])
useEffect(() => {
uploadMetadata()
}, [primitiveMetadata, uploadMetadata])
}

View File

@ -12,7 +12,7 @@
import { Route as rootRoute } from './routes/__root'
import { Route as IndexImport } from './routes/index'
import { Route as SColumnImport } from './routes/s.$column'
import { Route as CColumnImport } from './routes/c.$column'
// Create/Update Routes
@ -21,8 +21,8 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const SColumnRoute = SColumnImport.update({
path: '/s/$column',
const CColumnRoute = CColumnImport.update({
path: '/c/$column',
getParentRoute: () => rootRoute,
} as any)
@ -37,11 +37,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/s/$column': {
id: '/s/$column'
path: '/s/$column'
fullPath: '/s/$column'
preLoaderRoute: typeof SColumnImport
'/c/$column': {
id: '/c/$column'
path: '/c/$column'
fullPath: '/c/$column'
preLoaderRoute: typeof CColumnImport
parentRoute: typeof rootRoute
}
}
@ -51,37 +51,37 @@ declare module '@tanstack/react-router' {
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/s/$column': typeof SColumnRoute
'/c/$column': typeof CColumnRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/s/$column': typeof SColumnRoute
'/c/$column': typeof CColumnRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/s/$column': typeof SColumnRoute
'/c/$column': typeof CColumnRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/s/$column'
fullPaths: '/' | '/c/$column'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/s/$column'
id: '__root__' | '/' | '/s/$column'
to: '/' | '/c/$column'
id: '__root__' | '/' | '/c/$column'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SColumnRoute: typeof SColumnRoute
CColumnRoute: typeof CColumnRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SColumnRoute: SColumnRoute,
CColumnRoute: CColumnRoute,
}
export const routeTree = rootRoute
@ -97,14 +97,14 @@ export const routeTree = rootRoute
"filePath": "__root.tsx",
"children": [
"/",
"/s/$column"
"/c/$column"
]
},
"/": {
"filePath": "index.tsx"
},
"/s/$column": {
"filePath": "s.$column.tsx"
"/c/$column": {
"filePath": "c.$column.tsx"
}
}
}

View File

@ -9,6 +9,7 @@ import clsx from "clsx"
import { Header } from "~/components/header"
import { useOnReload } from "~/hooks/useOnReload"
import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar"
import { useSync } from "~/hooks/useSync"
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
@ -39,6 +40,7 @@ function NotFoundComponent() {
function RootComponent() {
useOnReload()
useSync()
return (
<>
<GlobalOverlayScrollbar className={clsx([

View File

@ -2,13 +2,12 @@ import { createFileRoute, redirect } from "@tanstack/react-router"
import { columnIds } from "@shared/metadata"
import { Column } from "~/components/column"
export const Route = createFileRoute("/s/$column")({
export const Route = createFileRoute("/c/$column")({
component: SectionComponent,
params: {
parse: (params) => {
const column = columnIds.find(x => x === params.column.toLowerCase())
if (!column)
throw new Error(`"${params.column}" is not a valid column.`)
if (!column) throw new Error(`"${params.column}" is not a valid column.`)
return {
column,
}

View File

@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import { useAtomValue } from "jotai"
import { useMemo } from "react"
import { localSourcesAtom } from "~/atoms"
import { focusSourcesAtom } from "~/atoms"
import { Column } from "~/components/column"
import { } from "cookie-es"
@ -10,9 +10,9 @@ export const Route = createFileRoute("/")({
})
function IndexComponent() {
const focusSources = useAtomValue(localSourcesAtom)
const focusSources = useAtomValue(focusSourcesAtom)
const id = useMemo(() => {
return focusSources.focus.length ? "focus" : "realtime"
return focusSources.length ? "focus" : "realtime"
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <Column id={id} />