From 87175aa8b2abe43240ba97313be0bff7cbeab6b1 Mon Sep 17 00:00:00 2001 From: Piotr Rogowski Date: Sun, 6 Nov 2022 15:22:10 +0100 Subject: [PATCH] Add Stargazers (#878) --- src/@types/pocketbase-types.ts | 88 +++++++++++++++++-------------- src/components/StarButton.tsx | 96 ++++++++++++++++++++++++++++++++++ src/contexts/AuthContext.tsx | 9 +++- src/hooks/useDb.ts | 53 ++++++++++++++++++- src/pages/Hub.tsx | 1 - src/pages/Info.tsx | 2 + 6 files changed, 206 insertions(+), 43 deletions(-) create mode 100644 src/components/StarButton.tsx diff --git a/src/@types/pocketbase-types.ts b/src/@types/pocketbase-types.ts index 6983081f..5688cce3 100644 --- a/src/@types/pocketbase-types.ts +++ b/src/@types/pocketbase-types.ts @@ -7,58 +7,67 @@ export type RecordIdString = string export type UserIdString = string export type BaseRecord = { - id: RecordIdString - created: IsoDateString - updated: IsoDateString - collectionId: string - collectionName: string - expand?: { [key: string]: any } + id: RecordIdString + created: IsoDateString + updated: IsoDateString + collectionId: string + collectionName: string + expand?: { [key: string]: any } } export enum Collections { - IniFiles = 'iniFiles', - Tunes = 'tunes', - Users = 'users', + IniFiles = 'iniFiles', + Stargazers = 'stargazers', + Tunes = 'tunes', + Users = 'users', } export type IniFilesRecord = { - signature: string - file: string - ecosystem: 'speeduino' | 'rusefi' + signature: string + file: string + ecosystem: 'speeduino' | 'rusefi' } export type IniFilesResponse = IniFilesRecord & BaseRecord +export type StargazersRecord = { + user: RecordIdString + tune: RecordIdString +} + +export type StargazersResponse = StargazersRecord & BaseRecord + export type TunesRecord = { - author: RecordIdString - tuneId: string - signature: string - vehicleName: string - engineMake: string - engineCode: string - displacement: number - cylindersCount: number - aspiration: 'na' | 'turbocharged' | 'supercharged' - compression?: number - fuel?: string - ignition?: string - injectorsSize?: number - year?: number - hp?: number - stockHp?: number - readme: string - textSearch: string - visibility: 'public' | 'unlisted' - tuneFile: string - customIniFile?: string - logFiles?: string[] - toothLogFiles?: string[] + author: RecordIdString + tuneId: string + signature: string + stars?: number + vehicleName: string + engineMake: string + engineCode: string + displacement: number + cylindersCount: number + aspiration: 'na' | 'turbocharged' | 'supercharged' + compression?: number + fuel?: string + ignition?: string + injectorsSize?: number + year?: number + hp?: number + stockHp?: number + readme: string + textSearch: string + visibility: 'public' | 'unlisted' + tuneFile: string + customIniFile?: string + logFiles?: string[] + toothLogFiles?: string[] } export type TunesResponse = TunesRecord & BaseRecord export type UsersRecord = { - avatar?: string + avatar?: string username: string email: string verified: boolean @@ -67,7 +76,8 @@ export type UsersRecord = { export type UsersResponse = UsersRecord & BaseRecord export type CollectionRecords = { - iniFiles: IniFilesRecord - tunes: TunesRecord - users: UsersRecord + iniFiles: IniFilesRecord + stargazers: StargazersRecord + tunes: TunesRecord + users: UsersRecord } diff --git a/src/components/StarButton.tsx b/src/components/StarButton.tsx new file mode 100644 index 00000000..fd204046 --- /dev/null +++ b/src/components/StarButton.tsx @@ -0,0 +1,96 @@ +import { + useEffect, + useState, +} from 'react'; +import { + Badge, + Button, + Space, + Tooltip, +} from 'antd'; +import { + StarOutlined, + StarFilled, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { Colors } from '../utils/colors'; +import { TuneDataState } from '../types/state'; +import useDb from '../hooks/useDb'; +import { useAuth } from '../contexts/AuthContext'; +import { Routes } from '../routes'; + +const StarButton = ({ tuneData }: { tuneData: TuneDataState }) => { + const navigate = useNavigate(); + const { currentUserToken } = useAuth(); + const { toggleStar, isStarredByMe } = useDb(); + const [currentStars, setCurrentStars] = useState(tuneData.stars); + const [isCurrentlyStarred, setIsCurrentlyStarred] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const toggleStarClick = async () => { + if (!currentUserToken) { + navigate(Routes.LOGIN); + + return; + } + + try { + setIsLoading(true); + const { stars, isStarred } = await toggleStar(currentUserToken, tuneData.id); + setCurrentStars(stars); + setIsCurrentlyStarred(isStarred); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + throw error; + } + }; + + useEffect(() => { + if (!currentUserToken) { + setIsLoading(false); + + return; + } + + setIsLoading(true); + isStarredByMe(currentUserToken, tuneData.id).then((isStarred) => { + setIsCurrentlyStarred(isStarred); + setIsLoading(false); + }).catch((error) => { + setIsLoading(false); + throw error; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUserToken, tuneData.id]); + + return ( +
+ + + +
+ ); +}; + +export default StarButton; + + + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index afeb424f..37290217 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -42,6 +42,7 @@ export enum OAuthProviders { interface AuthValue { currentUser: UsersResponse | null, + currentUserToken: string | null, signUp: (email: string, password: string, username: string) => Promise, login: (email: string, password: string) => Promise, refreshUser: () => Promise, @@ -64,9 +65,11 @@ const users = client.collection(Collections.Users); const AuthProvider = (props: { children: ReactNode }) => { const { children } = props; const [currentUser, setCurrentUser] = useState(null); + const [currentUserToken, setCurrentUserToken] = useState(null); const value = useMemo(() => ({ currentUser, + currentUserToken, signUp: async (email: string, password: string, username: string) => { try { const user = await users.create({ @@ -161,13 +164,15 @@ const AuthProvider = (props: { children: ReactNode }) => { return Promise.reject(new Error(formatError(error))); } }, - }), [currentUser]); + }), [currentUser, currentUserToken]); useEffect(() => { setCurrentUser(client.authStore.model as UsersResponse | null); + setCurrentUserToken(client.authStore.token); - const storeUnsubscribe = client.authStore.onChange((_token, model) => { + const storeUnsubscribe = client.authStore.onChange((token, model) => { setCurrentUser(model as UsersResponse | null); + setCurrentUserToken(token); }); return () => { diff --git a/src/hooks/useDb.ts b/src/hooks/useDb.ts index 77e1d638..682431b6 100644 --- a/src/hooks/useDb.ts +++ b/src/hooks/useDb.ts @@ -28,6 +28,11 @@ const tunesCollection = client.collection(Collections.Tunes); const customEndpoint = `${API_URL}/api/custom`; +const headers = (token: string) => ({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, +}); + const useDb = () => { const updateTune = async (id: string, data: TunesRecordPartial): Promise => { try { @@ -97,7 +102,7 @@ const useDb = () => { try { const list = await tunesCollection.getList(page, perPage, { - sort: '-updated', + sort: '-stars,-updated', filter, expand: 'author', }); @@ -161,6 +166,50 @@ const useDb = () => { } }; + const toggleStar = async (currentUserToken: string, tune: string): Promise<{ stars: number, isStarred: boolean }> => { + const response = await fetch(`${customEndpoint}/stargazers/toggleStar`, { + method: 'POST', + headers: headers(currentUserToken), + body: JSON.stringify({ tune }), + }); + + if (response.ok) { + const { stars, isStarred } = await response.json(); + + return Promise.resolve({ stars, isStarred }); + } + + if (response.status === 404) { + return Promise.resolve({ stars: 0, isStarred: false }); + } + + Sentry.captureException(response); + databaseGenericError(new Error(response.statusText)); + + return Promise.reject(response.status); + }; + + const isStarredByMe = async (currentUserToken: string, tune: string): Promise => { + const response = await fetch(`${customEndpoint}/stargazers/starredByMe/${tune}`, { + headers: headers(currentUserToken), + }); + + if (response.ok) { + const { isStarred } = await response.json(); + + return Promise.resolve(isStarred); + } + + if (response.status === 404) { + return Promise.resolve(false); + } + + Sentry.captureException(response); + databaseGenericError(new Error(response.statusText)); + + return Promise.reject(response.status); + }; + return { updateTune, createTune, @@ -169,6 +218,8 @@ const useDb = () => { searchTunes, getUserTunes, autocomplete, + toggleStar, + isStarredByMe, }; }; diff --git a/src/pages/Hub.tsx b/src/pages/Hub.tsx index d19b213c..6d8fa80e 100644 --- a/src/pages/Hub.tsx +++ b/src/pages/Hub.tsx @@ -77,7 +77,6 @@ const Hub = () => { displacement: `${tune.displacement}l`, aspiration: aspirationMapper[tune.aspiration], updated: formatTime(tune.updated), - stars: 0, })); setDataSource(mapped as any); } catch (error) { diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index d2804c8e..9a9a7946 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -23,6 +23,7 @@ import { Routes } from '../routes'; import { useAuth } from '../contexts/AuthContext'; import { formatTime } from '../utils/time'; import { UsersResponse } from '../@types/pocketbase-types'; +import StarButton from '../components/StarButton'; const { Item } = Form; const rowProps = { gutter: 10 }; @@ -66,6 +67,7 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => { return (
+ Details