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 (
+
+
+ : }
+ onClick={toggleStarClick}
+ loading={isLoading}
+ >
+
+ {isCurrentlyStarred ? 'Starred' : 'Star'}
+
+
+
+
+
+ );
+};
+
+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 (