mirror of
https://github.com/ourongxing/newsnow.git
synced 2025-01-19 03:09:14 +08:00
feat: sync user action
This commit is contained in:
parent
051c827ac1
commit
e8a2a7e6f0
@ -53,7 +53,8 @@
|
|||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-use": "^17.5.1"
|
"react-use": "^17.5.1",
|
||||||
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint-react/eslint-plugin": "^1.14.3",
|
"@eslint-react/eslint-plugin": "^1.14.3",
|
||||||
|
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -108,6 +108,9 @@ importers:
|
|||||||
react-use:
|
react-use:
|
||||||
specifier: ^17.5.1
|
specifier: ^17.5.1
|
||||||
version: 17.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.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:
|
devDependencies:
|
||||||
'@eslint-react/eslint-plugin':
|
'@eslint-react/eslint-plugin':
|
||||||
specifier: ^1.14.3
|
specifier: ^1.14.3
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS cache;
|
|
||||||
CREATE TABLE IF NOT EXISTS cache (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
updated INTEGER,
|
|
||||||
data TEXT
|
|
||||||
);
|
|
@ -29,7 +29,7 @@ export class UserTable {
|
|||||||
.run(id, email, "", type, now, now)
|
.run(id, email, "", type, now, now)
|
||||||
logger.success(`add user ${id}`)
|
logger.success(`add user ${id}`)
|
||||||
} else if (u.email !== email && u.type !== type) {
|
} 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`)
|
logger.success(`update user ${id} email`)
|
||||||
} else {
|
} else {
|
||||||
logger.info(`user ${id} already exists`)
|
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
|
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) {
|
async setData(key: string, value: string, updatedTime = Date.now()) {
|
||||||
const now = Date.now()
|
|
||||||
const state = await this.db.prepare(
|
const state = await this.db.prepare(
|
||||||
`REPLACE INTO user (id, data, updated) VALUES (?, ?, ?)`,
|
`UPDATE user SET data = ?, updated = ? WHERE id = ?`,
|
||||||
).run(key, JSON.stringify(value), now)
|
).run(value, updatedTime, key)
|
||||||
if (!state.success) throw new Error(`set user ${key} data failed`)
|
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) {
|
async getData(id: string) {
|
||||||
const row: any = await this.db.prepare(`SELECT data, update FROM user WHERE id = ?`).get(id)
|
const row: any = await this.db.prepare(`SELECT data, updated FROM user WHERE id = ?`).get(id)
|
||||||
if (!row || !row.data) throw new Error(`user ${id} not found`)
|
if (!row) throw new Error(`user ${id} not found`)
|
||||||
logger.success(`get ${id} data`)
|
logger.success(`get ${id} data`)
|
||||||
return row.data as {
|
return row as {
|
||||||
data: string
|
data: string
|
||||||
updated: number
|
updated: number
|
||||||
}
|
}
|
||||||
|
@ -2,10 +2,11 @@ import process from "node:process"
|
|||||||
import { jwtVerify } from "jose"
|
import { jwtVerify } from "jose"
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
const url = getRequestURL(event)
|
||||||
if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) {
|
if (["JWT_SECRET", "G_CLIENT_ID", "G_CLIENT_SECRET"].find(k => !process.env[k])) {
|
||||||
event.context.disabledLogin = true
|
event.context.disabledLogin = true
|
||||||
|
if (url.pathname.startsWith("/me")) throw createError({ statusCode: 506, message: "Server not configured" })
|
||||||
} else {
|
} else {
|
||||||
const url = getRequestURL(event)
|
|
||||||
if (/^\/(?:me|s)\//.test(url.pathname)) {
|
if (/^\/(?:me|s)\//.test(url.pathname)) {
|
||||||
const token = getHeader(event, "Authorization")
|
const token = getHeader(event, "Authorization")
|
||||||
if (token && process.env.JWT_SECRET) {
|
if (token && process.env.JWT_SECRET) {
|
||||||
@ -18,7 +19,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("JWT verification failed")
|
if (url.pathname.startsWith("/me")) throw createError({ statusCode: 401, message: "JWT verification failed" })
|
||||||
|
logger.warn("JWT verification failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5
server/routes/me/index.ts
Normal file
5
server/routes/me/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default defineEventHandler(() => {
|
||||||
|
return {
|
||||||
|
hello: "world",
|
||||||
|
}
|
||||||
|
})
|
33
server/routes/me/sync.ts
Normal file
33
server/routes/me/sync.ts
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
@ -43,6 +43,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log(userInfo)
|
||||||
const userID = String(userInfo.id)
|
const userID = String(userInfo.id)
|
||||||
await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github")
|
await userTable.addUser(userID, userInfo.notification_email || userInfo.email, "github")
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
|
|||||||
const isValid = (id: SourceID) => !id || !sources[id] || !sourcesFn[id]
|
const isValid = (id: SourceID) => !id || !sources[id] || !sourcesFn[id]
|
||||||
|
|
||||||
if (isValid(id)) {
|
if (isValid(id)) {
|
||||||
const redirectID = sources[id].redirect
|
const redirectID = sources?.[id].redirect
|
||||||
if (redirectID) id = redirectID
|
if (redirectID) id = redirectID
|
||||||
if (isValid(id)) throw new Error("Invalid source id")
|
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) {
|
if (now - cache.updated < interval) {
|
||||||
return {
|
return {
|
||||||
status: "success",
|
status: "success",
|
||||||
data: {
|
updatedTime: now,
|
||||||
updatedTime: now,
|
items: cache.data,
|
||||||
items: cache.data,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,10 +48,8 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
|
|||||||
if (event.context.disabledLogin) {
|
if (event.context.disabledLogin) {
|
||||||
return {
|
return {
|
||||||
status: "cache",
|
status: "cache",
|
||||||
data: {
|
updatedTime: cache.updated,
|
||||||
updatedTime: cache.updated,
|
items: cache.data,
|
||||||
items: cache.data,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,16 +62,14 @@ export default defineEventHandler(async (event): Promise<SourceResponse> => {
|
|||||||
if (cacheTable) event.waitUntil(cacheTable.set(id, data))
|
if (cacheTable) event.waitUntil(cacheTable.set(id, data))
|
||||||
return {
|
return {
|
||||||
status: "success",
|
status: "success",
|
||||||
data: {
|
updatedTime: now,
|
||||||
updatedTime: now,
|
items: data,
|
||||||
items: data,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e)
|
logger.error(e)
|
||||||
return {
|
throw createError({
|
||||||
status: "error",
|
statusCode: 500,
|
||||||
message: e.message ?? e,
|
message: e instanceof Error ? e.message : "Internal Server Error",
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -18,6 +18,12 @@ export type SourceID = {
|
|||||||
export type ColumnID = (typeof columnIds)[number]
|
export type ColumnID = (typeof columnIds)[number]
|
||||||
export type Metadata = Record<ColumnID, Column>
|
export type Metadata = Record<ColumnID, Column>
|
||||||
|
|
||||||
|
export interface PrimitiveMetadata {
|
||||||
|
updatedTime: number
|
||||||
|
data: Record<ColumnID, SourceID[]>
|
||||||
|
action: "init" | "manual" | "sync"
|
||||||
|
}
|
||||||
|
|
||||||
export interface OriginSource {
|
export interface OriginSource {
|
||||||
name: string
|
name: string
|
||||||
title?: string
|
title?: string
|
||||||
@ -63,16 +69,8 @@ export interface NewsItem {
|
|||||||
extra?: Record<string, any>
|
extra?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路由数据
|
export interface SourceResponse {
|
||||||
export interface SourceInfo {
|
status: "success" | "cache"
|
||||||
updatedTime: number | string
|
updatedTime: number | string
|
||||||
items: NewsItem[]
|
items: NewsItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SourceResponse = {
|
|
||||||
status: "success" | "cache"
|
|
||||||
data: SourceInfo
|
|
||||||
} | {
|
|
||||||
status: "error"
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
8
shared/verify.ts
Normal file
8
shared/verify.ts
Normal 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)
|
||||||
|
}
|
@ -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)
|
|
@ -1,32 +1,22 @@
|
|||||||
import { atom } from "jotai"
|
import { atom } from "jotai"
|
||||||
import type { ColumnID, SourceID } from "@shared/types"
|
import type { ColumnID, SourceID } from "@shared/types"
|
||||||
import { metadata } from "@shared/metadata"
|
|
||||||
import { sources } from "@shared/sources"
|
import { sources } from "@shared/sources"
|
||||||
import { typeSafeObjectEntries, typeSafeObjectFromEntries } from "@shared/type.util"
|
import { primitiveMetadataAtom } from "./primitiveMetadataAtom"
|
||||||
import { atomWithLocalStorage } from "./atomWithLocalStorage"
|
import type { Update } from "./types"
|
||||||
|
|
||||||
const initialSources = typeSafeObjectFromEntries(typeSafeObjectEntries(metadata).map(([id, val]) => [id, val.sources]))
|
export { primitiveMetadataAtom, preprocessMetadata } from "./primitiveMetadataAtom"
|
||||||
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 const focusSourcesAtom = atom((get) => {
|
export const focusSourcesAtom = atom((get) => {
|
||||||
return get(localSourcesAtom).focus
|
return get(primitiveMetadataAtom).data.focus
|
||||||
}, (get, set, update: Update<SourceID[]>) => {
|
}, (get, set, update: Update<SourceID[]>) => {
|
||||||
const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update
|
const _ = update instanceof Function ? update(get(focusSourcesAtom)) : update
|
||||||
set(localSourcesAtom, {
|
set(primitiveMetadataAtom, {
|
||||||
...get(localSourcesAtom),
|
updatedTime: Date.now(),
|
||||||
focus: _,
|
action: "manual",
|
||||||
|
data: {
|
||||||
|
...get(primitiveMetadataAtom).data,
|
||||||
|
focus: _,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -47,19 +37,21 @@ export const refetchSourcesAtom = atom(initRefetchSources())
|
|||||||
|
|
||||||
export const currentColumnIDAtom = atom<ColumnID>("focus")
|
export const currentColumnIDAtom = atom<ColumnID>("focus")
|
||||||
|
|
||||||
export const currentColumnAtom = atom((get) => {
|
export const currentSourcesAtom = atom((get) => {
|
||||||
const id = get(currentColumnIDAtom)
|
const id = get(currentColumnIDAtom)
|
||||||
return get(localSourcesAtom)[id]
|
return get(primitiveMetadataAtom).data[id]
|
||||||
}, (get, set, update: Update<SourceID[]>) => {
|
}, (get, set, update: Update<SourceID[]>) => {
|
||||||
const _ = update instanceof Function ? update(get(currentColumnAtom)) : update
|
const _ = update instanceof Function ? update(get(currentSourcesAtom)) : update
|
||||||
set(localSourcesAtom, {
|
set(primitiveMetadataAtom, {
|
||||||
...get(localSourcesAtom),
|
updatedTime: Date.now(),
|
||||||
[get(currentColumnIDAtom)]: _,
|
action: "manual",
|
||||||
|
data: {
|
||||||
|
...get(primitiveMetadataAtom).data,
|
||||||
|
[get(currentColumnIDAtom)]: _,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Update<T> = T | ((prev: T) => T)
|
|
||||||
|
|
||||||
export const goToTopAtom = atom({
|
export const goToTopAtom = atom({
|
||||||
ok: false,
|
ok: false,
|
||||||
fn: undefined as (() => void) | undefined,
|
fn: undefined as (() => void) | undefined,
|
||||||
|
62
src/atoms/primitiveMetadataAtom.ts
Normal file
62
src/atoms/primitiveMetadataAtom.ts
Normal 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
1
src/atoms/types.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type Update<T> = T | ((prev: T) => T)
|
@ -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 type { UseQueryResult } from "@tanstack/react-query"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
@ -28,7 +28,7 @@ interface NewsCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Query {
|
interface Query {
|
||||||
query: UseQueryResult<SourceInfo, Error>
|
query: UseQueryResult<SourceResponse, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardWrapper = forwardRef<HTMLDivElement, ItemsProps>(({ id, isDragged, handleListeners, style, ...props }, dndRef) => {
|
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")}`,
|
Authorization: `Bearer ${localStorage.getItem("user_jwt")}`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (response.status === "error") {
|
return response
|
||||||
throw new Error(response.message)
|
|
||||||
} else {
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// refetch 时显示原有的数据
|
// refetch 时显示原有的数据
|
||||||
placeholderData: prev => prev,
|
placeholderData: prev => prev,
|
||||||
|
@ -17,10 +17,10 @@ import { CSS } from "@dnd-kit/utilities"
|
|||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import type { ItemsProps } from "./card"
|
import type { ItemsProps } from "./card"
|
||||||
import { CardOverlay, CardWrapper } from "./card"
|
import { CardOverlay, CardWrapper } from "./card"
|
||||||
import { currentColumnAtom } from "~/atoms"
|
import { currentSourcesAtom } from "~/atoms"
|
||||||
|
|
||||||
export function Dnd() {
|
export function Dnd() {
|
||||||
const [items, setItems] = useAtom(currentColumnAtom)
|
const [items, setItems] = useAtom(currentSourcesAtom)
|
||||||
return (
|
return (
|
||||||
<DndWrapper items={items} setItems={setItems}>
|
<DndWrapper items={items} setItems={setItems}>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
@ -7,7 +7,7 @@ import type { SourceID } from "@shared/types"
|
|||||||
import { Homepage, Version } from "@shared/consts"
|
import { Homepage, Version } from "@shared/consts"
|
||||||
import { useLocalStorage } from "react-use"
|
import { useLocalStorage } from "react-use"
|
||||||
import { useDark } from "~/hooks/useDark"
|
import { useDark } from "~/hooks/useDark"
|
||||||
import { currentColumnAtom, goToTopAtom, refetchSourcesAtom } from "~/atoms"
|
import { currentSourcesAtom, goToTopAtom, refetchSourcesAtom } from "~/atoms"
|
||||||
|
|
||||||
function ThemeToggle() {
|
function ThemeToggle() {
|
||||||
const { toggleDark } = useDark()
|
const { toggleDark } = useDark()
|
||||||
@ -75,19 +75,19 @@ export function GithubIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RefreshButton() {
|
function RefreshButton() {
|
||||||
const currentColumn = useAtomValue(currentColumnAtom)
|
const currentSources = useAtomValue(currentSourcesAtom)
|
||||||
const setRefetchSource = useSetAtom(refetchSourcesAtom)
|
const setRefetchSource = useSetAtom(refetchSourcesAtom)
|
||||||
const refreshAll = useCallback(() => {
|
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 => ({
|
setRefetchSource(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
...obj,
|
...obj,
|
||||||
}))
|
}))
|
||||||
}, [currentColumn, setRefetchSource])
|
}, [currentSources, setRefetchSource])
|
||||||
|
|
||||||
const isFetching = useIsFetching({
|
const isFetching = useIsFetching({
|
||||||
predicate: (query) => {
|
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
57
src/hooks/useSync.ts
Normal 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])
|
||||||
|
}
|
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
import { Route as IndexImport } from './routes/index'
|
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
|
// Create/Update Routes
|
||||||
|
|
||||||
@ -21,8 +21,8 @@ const IndexRoute = IndexImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
const SColumnRoute = SColumnImport.update({
|
const CColumnRoute = CColumnImport.update({
|
||||||
path: '/s/$column',
|
path: '/c/$column',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
@ -37,11 +37,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexImport
|
preLoaderRoute: typeof IndexImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
'/s/$column': {
|
'/c/$column': {
|
||||||
id: '/s/$column'
|
id: '/c/$column'
|
||||||
path: '/s/$column'
|
path: '/c/$column'
|
||||||
fullPath: '/s/$column'
|
fullPath: '/c/$column'
|
||||||
preLoaderRoute: typeof SColumnImport
|
preLoaderRoute: typeof CColumnImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,37 +51,37 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/s/$column': typeof SColumnRoute
|
'/c/$column': typeof CColumnRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/s/$column': typeof SColumnRoute
|
'/c/$column': typeof CColumnRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRoute
|
__root__: typeof rootRoute
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/s/$column': typeof SColumnRoute
|
'/c/$column': typeof CColumnRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/s/$column'
|
fullPaths: '/' | '/c/$column'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/s/$column'
|
to: '/' | '/c/$column'
|
||||||
id: '__root__' | '/' | '/s/$column'
|
id: '__root__' | '/' | '/c/$column'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
SColumnRoute: typeof SColumnRoute
|
CColumnRoute: typeof CColumnRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
SColumnRoute: SColumnRoute,
|
CColumnRoute: CColumnRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routeTree = rootRoute
|
export const routeTree = rootRoute
|
||||||
@ -97,14 +97,14 @@ export const routeTree = rootRoute
|
|||||||
"filePath": "__root.tsx",
|
"filePath": "__root.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
"/s/$column"
|
"/c/$column"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"/": {
|
"/": {
|
||||||
"filePath": "index.tsx"
|
"filePath": "index.tsx"
|
||||||
},
|
},
|
||||||
"/s/$column": {
|
"/c/$column": {
|
||||||
"filePath": "s.$column.tsx"
|
"filePath": "c.$column.tsx"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import clsx from "clsx"
|
|||||||
import { Header } from "~/components/header"
|
import { Header } from "~/components/header"
|
||||||
import { useOnReload } from "~/hooks/useOnReload"
|
import { useOnReload } from "~/hooks/useOnReload"
|
||||||
import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar"
|
import { GlobalOverlayScrollbar } from "~/components/common/overlay-scrollbar"
|
||||||
|
import { useSync } from "~/hooks/useSync"
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<{
|
export const Route = createRootRouteWithContext<{
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
@ -39,6 +40,7 @@ function NotFoundComponent() {
|
|||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
useOnReload()
|
useOnReload()
|
||||||
|
useSync()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GlobalOverlayScrollbar className={clsx([
|
<GlobalOverlayScrollbar className={clsx([
|
||||||
|
@ -2,13 +2,12 @@ import { createFileRoute, redirect } from "@tanstack/react-router"
|
|||||||
import { columnIds } from "@shared/metadata"
|
import { columnIds } from "@shared/metadata"
|
||||||
import { Column } from "~/components/column"
|
import { Column } from "~/components/column"
|
||||||
|
|
||||||
export const Route = createFileRoute("/s/$column")({
|
export const Route = createFileRoute("/c/$column")({
|
||||||
component: SectionComponent,
|
component: SectionComponent,
|
||||||
params: {
|
params: {
|
||||||
parse: (params) => {
|
parse: (params) => {
|
||||||
const column = columnIds.find(x => x === params.column.toLowerCase())
|
const column = columnIds.find(x => x === params.column.toLowerCase())
|
||||||
if (!column)
|
if (!column) throw new Error(`"${params.column}" is not a valid column.`)
|
||||||
throw new Error(`"${params.column}" is not a valid column.`)
|
|
||||||
return {
|
return {
|
||||||
column,
|
column,
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { useAtomValue } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { localSourcesAtom } from "~/atoms"
|
import { focusSourcesAtom } from "~/atoms"
|
||||||
import { Column } from "~/components/column"
|
import { Column } from "~/components/column"
|
||||||
import { } from "cookie-es"
|
import { } from "cookie-es"
|
||||||
|
|
||||||
@ -10,9 +10,9 @@ export const Route = createFileRoute("/")({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function IndexComponent() {
|
function IndexComponent() {
|
||||||
const focusSources = useAtomValue(localSourcesAtom)
|
const focusSources = useAtomValue(focusSourcesAtom)
|
||||||
const id = useMemo(() => {
|
const id = useMemo(() => {
|
||||||
return focusSources.focus.length ? "focus" : "realtime"
|
return focusSources.length ? "focus" : "realtime"
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
return <Column id={id} />
|
return <Column id={id} />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user