Migrate to PocketBase (#801)
This commit is contained in:
parent
e4cfe46c53
commit
1712e5c8bd
4
.env
4
.env
|
@ -1,10 +1,10 @@
|
|||
NPM_GITHUB_TOKEN=
|
||||
VITE_ENVIRONMENT=development
|
||||
VITE_WEB_URL=http://localhost:3000
|
||||
VITE_WEB_URL=http://localhost:5173
|
||||
VITE_SENTRY_DSN=
|
||||
VITE_GTM_ID=
|
||||
|
||||
# TODO: remove this later
|
||||
VITE_CDN_URL=
|
||||
|
||||
VITE_APPWRITE_ENDPOINT=
|
||||
VITE_POCKETBASE_API_URL=
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"cSpell.words": [
|
||||
"Appwrite",
|
||||
"kbar",
|
||||
"pocketbase",
|
||||
"prefs",
|
||||
"vite",
|
||||
"vitejs"
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -24,13 +24,13 @@
|
|||
"@reduxjs/toolkit": "^1.8.6",
|
||||
"@sentry/react": "^7.15.0",
|
||||
"@sentry/tracing": "^7.15.0",
|
||||
"antd": "^4.23.5",
|
||||
"appwrite": "10.1.0",
|
||||
"antd": "^4.23.6",
|
||||
"kbar": "^0.1.0-beta.36",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"mlg-converter": "^0.5.1",
|
||||
"nanoid": "^4.0.0",
|
||||
"pako": "^2.0.4",
|
||||
"pocketbase": "^0.7.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-ga4": "^1.4.1",
|
||||
|
@ -45,7 +45,7 @@
|
|||
"devDependencies": {
|
||||
"@hyper-tuner/eslint-config": "^0.1.6",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^18.8.5",
|
||||
"@types/node": "^18.11.0",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
|
@ -64,7 +64,7 @@
|
|||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"less": "^4.1.3",
|
||||
"prettier": "^2.7.1",
|
||||
"rollup-plugin-visualizer": "^5.8.2",
|
||||
"rollup-plugin-visualizer": "^5.8.3",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
// Generated using pocketbase-typegen
|
||||
|
||||
export enum Collections {
|
||||
Profiles = 'profiles',
|
||||
Tunes = 'tunes',
|
||||
}
|
||||
|
||||
export type ProfilesRecord = {
|
||||
userId: string;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export type TunesRecord = {
|
||||
user: string;
|
||||
userProfile: string;
|
||||
tuneId: string;
|
||||
signature: string;
|
||||
vehicleName: string;
|
||||
engineMake: string;
|
||||
engineCode: string;
|
||||
displacement: number;
|
||||
cylindersCount: number;
|
||||
aspiration: string;
|
||||
compression?: number;
|
||||
fuel?: string;
|
||||
ignition?: string;
|
||||
injectorsSize?: number;
|
||||
year?: number;
|
||||
hp?: number;
|
||||
stockHp?: number;
|
||||
readme: string;
|
||||
textSearch: string;
|
||||
visibility: string;
|
||||
tuneFile: string;
|
||||
customIniFile?: string;
|
||||
logFiles?: string[];
|
||||
toothLogFiles?: string[];
|
||||
}
|
51
src/App.tsx
51
src/App.tsx
|
@ -3,6 +3,7 @@ import {
|
|||
Route,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
generatePath,
|
||||
} from 'react-router-dom';
|
||||
import {
|
||||
Layout,
|
||||
|
@ -33,6 +34,7 @@ import {
|
|||
import useDb from './hooks/useDb';
|
||||
import Info from './pages/Info';
|
||||
import Hub from './pages/Hub';
|
||||
import { FormRoles } from './pages/auth/Login';
|
||||
|
||||
import 'react-perfect-scrollbar/dist/css/styles.css';
|
||||
import './css/App.less';
|
||||
|
@ -46,11 +48,10 @@ const Diagnose = lazy(() => import('./pages/Diagnose'));
|
|||
const Upload = lazy(() => import('./pages/Upload'));
|
||||
const Login = lazy(() => import('./pages/auth/Login'));
|
||||
const Profile = lazy(() => import('./pages/auth/Profile'));
|
||||
const SignUp = lazy(() => import('./pages/auth/SignUp'));
|
||||
const ResetPassword = lazy(() => import('./pages/auth/ResetPassword'));
|
||||
const MagicLinkConfirmation = lazy(() => import('./pages/auth/MagicLinkConfirmation'));
|
||||
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
|
||||
const ResetPasswordConfirmation = lazy(() => import('./pages/auth/ResetPasswordConfirmation'));
|
||||
const EmailVerification = lazy(() => import('./pages/auth/EmailVerification'));
|
||||
const OauthCallback = lazy(() => import('./pages/auth/OauthCallback'));
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
|
@ -64,27 +65,9 @@ const mapStateToProps = (state: AppState) => ({
|
|||
const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: NavigationState, tuneData: TuneDataState }) => {
|
||||
const margin = ui.sidebarCollapsed ? 80 : 250;
|
||||
const { getTune } = useDb();
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const redirectPage = searchParams.get('redirectPage');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { getBucketId } = useDb();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// TODO: refactor this
|
||||
switch (redirectPage) {
|
||||
case Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION:
|
||||
window.location.href = `/#${Routes.MAGIC_LINK_CONFIRMATION}?${searchParams.toString()}`;
|
||||
break;
|
||||
case Routes.REDIRECT_PAGE_EMAIL_VERIFICATION:
|
||||
window.location.href = `/#${Routes.EMAIL_VERIFICATION}?${searchParams.toString()}`;
|
||||
break;
|
||||
case Routes.REDIRECT_PAGE_RESET_PASSWORD:
|
||||
window.location.href = `/#${Routes.RESET_PASSWORD_CONFIRMATION}?${searchParams.toString()}`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// const [lastDialogPath, setLastDialogPath] = useState<string|null>();
|
||||
// const lastDialogPath = storageGetSync('lastDialog');
|
||||
|
||||
|
@ -92,11 +75,23 @@ const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: Navigation
|
|||
const tuneId = tunePathMatch?.params.tuneId;
|
||||
|
||||
useEffect(() => {
|
||||
// Handle external redirects (oauth, etc)
|
||||
// TODO: refactor this
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const redirectPage = searchParams.get('redirect');
|
||||
switch (redirectPage) {
|
||||
case Routes.REDIRECT_PAGE_OAUTH_CALLBACK:
|
||||
window.location.href = `/#${generatePath(Routes.OAUTH_CALLBACK, { provider: searchParams.get('provider')! })}?${searchParams.toString()}`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (tuneId) {
|
||||
// clear out last state
|
||||
if (tuneData && tuneId !== tuneData.tuneId) {
|
||||
setIsLoading(true);
|
||||
loadTune(null, '');
|
||||
loadTune(null);
|
||||
store.dispatch({ type: 'tuneData/load', payload: null });
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
@ -108,10 +103,8 @@ const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: Navigation
|
|||
return;
|
||||
}
|
||||
|
||||
getBucketId(tune.userId).then((bucketId) => {
|
||||
loadTune(tune!, bucketId);
|
||||
});
|
||||
store.dispatch({ type: 'tuneData/load', payload: tune });
|
||||
loadTune(tune!);
|
||||
store.dispatch({ type: 'tuneData/load', payload: JSON.parse(JSON.stringify(tune)) });
|
||||
});
|
||||
|
||||
store.dispatch({ type: 'navigation/tuneId', payload: tuneId });
|
||||
|
@ -158,14 +151,14 @@ const App = ({ ui, navigation, tuneData }: { ui: UIState, navigation: Navigation
|
|||
<Route path={Routes.TUNE_DIAGNOSE} element={<ContentFor marginLeft={margin} element={<Diagnose />} />} />
|
||||
<Route path={`${Routes.UPLOAD}/*`} element={<ContentFor element={<Upload />} />} />
|
||||
|
||||
<Route path={Routes.LOGIN} element={<ContentFor element={<Login />} />} />
|
||||
<Route path={Routes.LOGIN} element={<ContentFor element={<Login formRole={FormRoles.LOGIN} />} />} />
|
||||
<Route path={Routes.SIGN_UP} element={<ContentFor element={<Login formRole={FormRoles.SING_UP} />} />} />
|
||||
<Route path={Routes.PROFILE} element={<ContentFor element={<Profile />} />} />
|
||||
<Route path={Routes.SIGN_UP} element={<ContentFor element={<SignUp />} />} />
|
||||
<Route path={Routes.RESET_PASSWORD} element={<ContentFor element={<ResetPassword />} />} />
|
||||
|
||||
<Route path={Routes.MAGIC_LINK_CONFIRMATION} element={<ContentFor element={<MagicLinkConfirmation />} />} />
|
||||
<Route path={Routes.EMAIL_VERIFICATION} element={<ContentFor element={<EmailVerification />} />} />
|
||||
<Route path={Routes.RESET_PASSWORD_CONFIRMATION} element={<ContentFor element={<ResetPasswordConfirmation />} />} />
|
||||
<Route path={Routes.OAUTH_CALLBACK} element={<ContentFor element={<OauthCallback />} />} />
|
||||
</ReactRoutes>
|
||||
<Result status="warning" title="Page not found" style={{ marginTop: 50 }} />
|
||||
</Layout>
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
import {
|
||||
Account,
|
||||
Client,
|
||||
Databases,
|
||||
Functions,
|
||||
Storage,
|
||||
} from 'appwrite';
|
||||
import { fetchEnv } from './utils/env';
|
||||
|
||||
const client = new Client();
|
||||
|
||||
client
|
||||
.setEndpoint(fetchEnv('VITE_APPWRITE_ENDPOINT'))
|
||||
.setProject('hyper-tuner-api');
|
||||
|
||||
const account = new Account(client);
|
||||
const databases = new Databases(client);
|
||||
const storage = new Storage(client);
|
||||
const functions = new Functions(client);
|
||||
|
||||
export {
|
||||
client,
|
||||
account,
|
||||
databases,
|
||||
storage,
|
||||
functions,
|
||||
};
|
|
@ -46,10 +46,7 @@ import {
|
|||
} from '@hyper-tuner/types';
|
||||
import { Routes } from '../routes';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
logOutFailed,
|
||||
logOutSuccessful,
|
||||
} from '../pages/auth/notifications';
|
||||
import { logOutSuccessful } from '../pages/auth/notifications';
|
||||
import store from '../store';
|
||||
import { isMac } from '../utils/env';
|
||||
import {
|
||||
|
@ -323,15 +320,11 @@ const CommandPalette = (props: CommandPaletteProps) => {
|
|||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const logoutAction = useCallback(async () => {
|
||||
try {
|
||||
await logout();
|
||||
logOutSuccessful();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
logOutFailed(error as Error);
|
||||
}
|
||||
}, [logout]);
|
||||
const logoutAction = useCallback(() => {
|
||||
logout();
|
||||
logOutSuccessful();
|
||||
navigate(Routes.HUB);
|
||||
}, [logout, navigate]);
|
||||
|
||||
const initialActions = [
|
||||
{
|
||||
|
|
|
@ -51,10 +51,7 @@ import { isMac } from '../utils/env';
|
|||
import { isToggleSidebar } from '../utils/keyboard/shortcuts';
|
||||
import { Routes } from '../routes';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
logOutFailed,
|
||||
logOutSuccessful,
|
||||
} from '../pages/auth/notifications';
|
||||
import { logOutSuccessful } from '../pages/auth/notifications';
|
||||
|
||||
const { Header } = Layout;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
@ -73,15 +70,10 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
|
|||
const tabMatch = useMatch(`${Routes.TUNE_TAB}/*`);
|
||||
const uploadMatch = useMatch(Routes.UPLOAD);
|
||||
const hubMatch = useMatch(Routes.HUB);
|
||||
const logoutClick = useCallback(async () => {
|
||||
try {
|
||||
navigate(Routes.HUB);
|
||||
logout();
|
||||
logOutSuccessful();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
logOutFailed(error as Error);
|
||||
}
|
||||
const logoutClick = useCallback(() => {
|
||||
logout();
|
||||
logOutSuccessful();
|
||||
navigate(Routes.HUB);
|
||||
}, [logout, navigate]);
|
||||
|
||||
const toggleCommandPalette = useCallback(() => query.toggle(), [query]);
|
||||
|
@ -189,7 +181,7 @@ const TopBar = ({ tuneId }: { tuneId: string | null }) => {
|
|||
}], [currentUser, logoutClick, navigate]);
|
||||
|
||||
return (
|
||||
<Header className="app-top-bar" style={xs ? { padding: '0 5px' } : {} }>
|
||||
<Header className="app-top-bar" style={xs ? { padding: '0 5px' } : {}}>
|
||||
<Row>
|
||||
{tuneId ? tabs : (
|
||||
<Col span={10} md={14} sm={16}>
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
import {
|
||||
ID,
|
||||
Models,
|
||||
} from 'appwrite';
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
|
@ -10,208 +6,188 @@ import {
|
|||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { User } from 'pocketbase';
|
||||
import {
|
||||
account,
|
||||
client,
|
||||
} from '../appwrite';
|
||||
import Loader from '../components/Loader';
|
||||
formatError,
|
||||
} from '../pocketbase';
|
||||
import { buildRedirectUrl } from '../utils/url';
|
||||
import { Collections } from '../@types/pocketbase-types';
|
||||
import { Routes } from '../routes';
|
||||
import {
|
||||
buildFullUrl,
|
||||
buildRedirectUrl,
|
||||
} from '../utils/url';
|
||||
|
||||
export type SessionList = Models.SessionList;
|
||||
export type LogList = Models.LogList;
|
||||
export type Account = Models.Account<Models.Preferences>;
|
||||
// TODO: this should be imported from pocketbase but currently is not exported
|
||||
export type AuthProviderInfo = {
|
||||
name: string;
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
authUrl: string;
|
||||
};
|
||||
|
||||
// TODO: this should be imported from pocketbase but currently is not exported
|
||||
export type AuthMethodsList = {
|
||||
[key: string]: any;
|
||||
emailPassword: boolean;
|
||||
authProviders: Array<AuthProviderInfo>;
|
||||
};
|
||||
|
||||
export enum OAuthProviders {
|
||||
GOOGLE = 'google',
|
||||
GITHUB = 'github',
|
||||
FACEBOOK = 'facebook',
|
||||
};
|
||||
|
||||
interface AuthValue {
|
||||
currentUser: Account | null,
|
||||
signUp: (email: string, password: string, username: string) => Promise<Account>,
|
||||
login: (email: string, password: string) => Promise<Account>,
|
||||
sendMagicLink: (email: string) => Promise<void>,
|
||||
confirmMagicLink: (userId: string, secret: string) => Promise<Account>,
|
||||
currentUser: User | null,
|
||||
signUp: (email: string, password: string) => Promise<User>,
|
||||
login: (email: string, password: string) => Promise<User>,
|
||||
refreshUser: () => Promise<User | null>,
|
||||
sendEmailVerification: () => Promise<void>,
|
||||
confirmEmailVerification: (userId: string, secret: string) => Promise<void>,
|
||||
confirmResetPassword: (userId: string, secret: string, password: string) => Promise<void>,
|
||||
logout: () => Promise<void>,
|
||||
confirmEmailVerification: (token: string) => Promise<void>,
|
||||
confirmResetPassword: (token: string, password: string) => Promise<void>,
|
||||
logout: () => void,
|
||||
initResetPassword: (email: string) => Promise<void>,
|
||||
googleAuth: () => Promise<void>,
|
||||
githubAuth: () => Promise<void>,
|
||||
facebookAuth: () => Promise<void>,
|
||||
listAuthMethods: () => Promise<AuthMethodsList>,
|
||||
oAuth: (provider: OAuthProviders, code: string, codeVerifier: string) => Promise<void>,
|
||||
updateUsername: (username: string) => Promise<void>,
|
||||
updatePassword: (password: string, oldPassword: string) => Promise<void>,
|
||||
getSessions: () => Promise<SessionList>,
|
||||
getLogs: () => Promise<LogList>,
|
||||
}
|
||||
|
||||
const OAUTH_REDIRECT_URL = buildFullUrl();
|
||||
const MAGIC_LINK_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION);
|
||||
const EMAIL_VERIFICATION_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_EMAIL_VERIFICATION);
|
||||
const RESET_PASSWORD_REDIRECT_URL = buildRedirectUrl(Routes.REDIRECT_PAGE_RESET_PASSWORD);
|
||||
|
||||
const GOOGLE_SCOPES = ['https://www.googleapis.com/auth/userinfo.email'];
|
||||
const GITHUB_SCOPES = ['user:email'];
|
||||
const FACEBOOK_SCOPES = ['email'];
|
||||
|
||||
const AuthContext = createContext<AuthValue | null>(null);
|
||||
|
||||
const useAuth = () => useContext<AuthValue>(AuthContext as any);
|
||||
|
||||
const AuthProvider = (props: { children: ReactNode }) => {
|
||||
const { children } = props;
|
||||
const [currentUser, setCurrentUser] = useState<Account | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
currentUser,
|
||||
signUp: async (email: string, password: string, username: string) => {
|
||||
signUp: async (email: string, password: string) => {
|
||||
try {
|
||||
await account.create(ID.unique(), email, password, username);
|
||||
await account.createEmailSession(email, password);
|
||||
const user = await account.get();
|
||||
setCurrentUser(user);
|
||||
const user = await client.users.create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
});
|
||||
client.users.requestVerification(user.email);
|
||||
await client.users.authViaEmail(user.email, password);
|
||||
|
||||
return Promise.resolve(user);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
login: async (email: string, password: string) => {
|
||||
try {
|
||||
await account.createEmailSession(email, password);
|
||||
const user = await account.get();
|
||||
setCurrentUser(user);
|
||||
return Promise.resolve(user);
|
||||
const authResponse = await client.users.authViaEmail(email, password);
|
||||
return Promise.resolve(authResponse.user);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
sendMagicLink: async (email: string) => {
|
||||
refreshUser: async () => {
|
||||
try {
|
||||
await account.createMagicURLSession(ID.unique(), email, MAGIC_LINK_REDIRECT_URL);
|
||||
return Promise.resolve();
|
||||
const authResponse = await client.users.refresh();
|
||||
return Promise.resolve(authResponse.user);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
confirmMagicLink: async (userId: string, secret: string) => {
|
||||
try {
|
||||
await account.updateMagicURLSession(userId, secret);
|
||||
const user = await account.get();
|
||||
setCurrentUser(user);
|
||||
return Promise.resolve(user);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
client.authStore.clear();
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
},
|
||||
sendEmailVerification: async () => {
|
||||
try {
|
||||
await account.createVerification(EMAIL_VERIFICATION_REDIRECT_URL);
|
||||
await client.users.requestVerification(currentUser!.email);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
confirmEmailVerification: async (userId: string, secret: string) => {
|
||||
confirmEmailVerification: async (token: string) => {
|
||||
try {
|
||||
await account.updateVerification(userId, secret);
|
||||
const user = await account.get();
|
||||
setCurrentUser(user);
|
||||
await client.users.confirmVerification(token);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
confirmResetPassword: async (userId: string, secret: string, password: string) => {
|
||||
confirmResetPassword: async (token: string, password: string) => {
|
||||
try {
|
||||
await account.updateRecovery(userId, secret, password, password);
|
||||
await client.users.confirmPasswordReset(token, password, password);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await account.deleteSession('current');
|
||||
setCurrentUser(null);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
client.authStore.clear();
|
||||
},
|
||||
initResetPassword: async (email: string) => {
|
||||
try {
|
||||
await account.createRecovery(email, RESET_PASSWORD_REDIRECT_URL);
|
||||
await client.users.requestPasswordReset(email);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
googleAuth: async () => {
|
||||
account.createOAuth2Session(
|
||||
'google',
|
||||
OAUTH_REDIRECT_URL,
|
||||
OAUTH_REDIRECT_URL,
|
||||
GOOGLE_SCOPES,
|
||||
);
|
||||
listAuthMethods: async () => {
|
||||
try {
|
||||
const methods = await client.users.listAuthMethods();
|
||||
return Promise.resolve(methods);
|
||||
} catch (error) {
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
githubAuth: async () => {
|
||||
account.createOAuth2Session(
|
||||
'github',
|
||||
OAUTH_REDIRECT_URL,
|
||||
OAUTH_REDIRECT_URL,
|
||||
GITHUB_SCOPES,
|
||||
);
|
||||
},
|
||||
facebookAuth: async () => {
|
||||
account.createOAuth2Session(
|
||||
'facebook',
|
||||
OAUTH_REDIRECT_URL,
|
||||
OAUTH_REDIRECT_URL,
|
||||
FACEBOOK_SCOPES,
|
||||
oAuth: async (provider: OAuthProviders, code: string, codeVerifier: string) => {
|
||||
client.users.authViaOAuth2(
|
||||
provider,
|
||||
code,
|
||||
codeVerifier,
|
||||
buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider }),
|
||||
);
|
||||
},
|
||||
updateUsername: async (username: string) => {
|
||||
try {
|
||||
await account.updateName(username);
|
||||
const user = await account.get();
|
||||
setCurrentUser(user);
|
||||
await client.records.update(Collections.Profiles, currentUser!.profile!.id, {
|
||||
username,
|
||||
});
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(new Error(formatError(error)));
|
||||
}
|
||||
},
|
||||
updatePassword: async (password: string, oldPassword: string) => {
|
||||
try {
|
||||
await account.updatePassword(password, oldPassword);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
getSessions: () => account.listSessions(),
|
||||
getLogs: () => account.listLogs(),
|
||||
}), [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
account.get().then((user) => {
|
||||
console.info('Logged as:', user.name || 'Unknown');
|
||||
setCurrentUser(user);
|
||||
setIsLoading(false);
|
||||
}).catch(() => {
|
||||
console.info('User not logged in');
|
||||
}).finally(() => setIsLoading(false));
|
||||
setCurrentUser(client.authStore.model as User | null);
|
||||
|
||||
const unsubscribe = client.subscribe('account', (event) => {
|
||||
console.info('Account event', event);
|
||||
const storeUnsubscribe = client.authStore.onChange((_token, model) => {
|
||||
setCurrentUser(model as User | null);
|
||||
if (model) {
|
||||
console.info('Logged in as', model.email);
|
||||
} else {
|
||||
console.info('Logged out');
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
client.realtime.subscribe(Collections.Tunes, (event) => {
|
||||
console.info('Tunes event', event);
|
||||
});
|
||||
|
||||
client.realtime.subscribe(Collections.Profiles, (event) => {
|
||||
console.info('Profiles event', event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
storeUnsubscribe();
|
||||
client.realtime.unsubscribe(Collections.Tunes);
|
||||
client.realtime.unsubscribe(Collections.Profiles);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{isLoading ? <Loader /> : children}
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// darker
|
||||
@text: #CECECE;
|
||||
@main: #191C1E;
|
||||
@main-dark: #1E1E1E;
|
||||
@main-dark: #2a2a2a;
|
||||
@main-light: #252525;
|
||||
@main-darkest: #171717;
|
||||
|
||||
|
|
|
@ -1,60 +1,39 @@
|
|||
import * as Sentry from '@sentry/browser';
|
||||
import {
|
||||
ID,
|
||||
Models,
|
||||
Permission,
|
||||
Query,
|
||||
Role,
|
||||
} from 'appwrite';
|
||||
client,
|
||||
formatError,
|
||||
} from '../pocketbase';
|
||||
import {
|
||||
databases,
|
||||
functions,
|
||||
} from '../appwrite';
|
||||
import {
|
||||
TuneDbData,
|
||||
UsersBucket,
|
||||
TuneDbDataPartial,
|
||||
TuneDbDocument,
|
||||
TunesRecordFull,
|
||||
TunesRecordPartial,
|
||||
} from '../types/dbData';
|
||||
import { databaseGenericError } from '../pages/auth/notifications';
|
||||
|
||||
const DB_ID = 'public';
|
||||
const COLLECTION_ID_PUBLIC_TUNES = 'tunes';
|
||||
const COLLECTION_ID_USERS_BUCKETS = 'usersBuckets';
|
||||
import {
|
||||
Collections,
|
||||
TunesRecord,
|
||||
} from '../@types/pocketbase-types';
|
||||
|
||||
const useDb = () => {
|
||||
const updateTune = async (documentId: string, data: TuneDbDataPartial) => {
|
||||
const updateTune = async (id: string, data: TunesRecordPartial) => {
|
||||
try {
|
||||
await databases.updateDocument(DB_ID, COLLECTION_ID_PUBLIC_TUNES, documentId, data);
|
||||
|
||||
await client.records.update(Collections.Tunes, id, data);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
databaseGenericError(error as Error);
|
||||
databaseGenericError(new Error(formatError(error)));
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const createTune = async (data: TuneDbData) => {
|
||||
const createTune = async (data: TunesRecord) => {
|
||||
try {
|
||||
const tune = await databases.createDocument(
|
||||
DB_ID,
|
||||
COLLECTION_ID_PUBLIC_TUNES,
|
||||
ID.unique(),
|
||||
data,
|
||||
[
|
||||
Permission.read(Role.any()),
|
||||
Permission.write(Role.user(data.userId, 'verified')),
|
||||
],
|
||||
);
|
||||
const record = await client.records.create(Collections.Tunes, data);
|
||||
|
||||
return Promise.resolve(tune);
|
||||
return Promise.resolve(record as TunesRecordFull);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
databaseGenericError(error as Error);
|
||||
databaseGenericError(new Error(formatError(error)));
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
@ -62,46 +41,15 @@ const useDb = () => {
|
|||
|
||||
const getTune = async (tuneId: string) => {
|
||||
try {
|
||||
const tune = await databases.listDocuments(
|
||||
DB_ID,
|
||||
COLLECTION_ID_PUBLIC_TUNES,
|
||||
[
|
||||
Query.equal('tuneId', tuneId),
|
||||
Query.limit(1),
|
||||
],
|
||||
);
|
||||
const tune = await client.records.getList(Collections.Tunes, 1, 1, {
|
||||
filter: `tuneId = "${tuneId}"`,
|
||||
expand: 'userProfile',
|
||||
});
|
||||
|
||||
return Promise.resolve(tune.total > 0 ? tune.documents[0] as unknown as TuneDbDocument : null);
|
||||
return Promise.resolve(tune.totalItems > 0 ? tune.items[0] as TunesRecordFull : null);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
databaseGenericError(error as Error);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getBucketId = async (userId: string) => {
|
||||
try {
|
||||
const buckets = await databases.listDocuments(
|
||||
DB_ID,
|
||||
COLLECTION_ID_USERS_BUCKETS,
|
||||
[
|
||||
Query.equal('userId', userId),
|
||||
Query.equal('visibility', 'public'),
|
||||
Query.limit(1),
|
||||
],
|
||||
);
|
||||
|
||||
if (buckets.total === 0) {
|
||||
throw new Error('No public bucket found');
|
||||
}
|
||||
|
||||
return Promise.resolve((buckets.documents[0] as unknown as UsersBucket)!.bucketId);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
databaseGenericError(error as Error);
|
||||
databaseGenericError(new Error(formatError(error)));
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
@ -109,34 +57,34 @@ const useDb = () => {
|
|||
|
||||
const searchTunes = async (search?: string) => {
|
||||
// TODO: add pagination
|
||||
const limit = 100;
|
||||
const batchSide = 100;
|
||||
const phrases = search ? search.replace(/ +(?= )/g,'').split(' ') : [];
|
||||
const filter = phrases
|
||||
.filter((phrase) => phrase.length > 1)
|
||||
.map((phrase) => `textSearch ~ "${phrase}"`)
|
||||
.join(' || ');
|
||||
|
||||
try {
|
||||
const list: Models.DocumentList<TuneDbDocument> = await (
|
||||
search
|
||||
? databases.listDocuments(DB_ID, COLLECTION_ID_PUBLIC_TUNES, [Query.search('textSearch', search), Query.limit(limit)])
|
||||
: databases.listDocuments(DB_ID, COLLECTION_ID_PUBLIC_TUNES, [Query.limit(limit)])
|
||||
);
|
||||
const list = await client.records.getFullList(Collections.Tunes, batchSide, {
|
||||
sort: '-created',
|
||||
filter,
|
||||
expand: 'userProfile',
|
||||
});
|
||||
|
||||
return Promise.resolve(list);
|
||||
return Promise.resolve(list as TunesRecordFull[]);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
databaseGenericError(error as Error);
|
||||
databaseGenericError(new Error(formatError(error)));
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
updateTune: (tuneId: string, data: TuneDbDataPartial): Promise<void> => updateTune(tuneId, data),
|
||||
createTune: (data: TuneDbData): Promise<Models.Document> => createTune(data),
|
||||
getTune: (tuneId: string): Promise<TuneDbDocument | null> => getTune(tuneId),
|
||||
searchTunes: (search?: string): Promise<Models.DocumentList<TuneDbDocument>> => searchTunes(search),
|
||||
getBucketId: (userId: string): Promise<string> => getBucketId(userId),
|
||||
// TODO: refactor those executions
|
||||
getUser: (userId: string) => functions.createExecution('getUser', JSON.stringify({ userId })),
|
||||
listUsers: (userIds: string[]) => functions.createExecution('listUsers', JSON.stringify({ userIds })),
|
||||
updateTune: (tuneId: string, data: TunesRecordPartial): Promise<void> => updateTune(tuneId, data),
|
||||
createTune: (data: TunesRecord): Promise<TunesRecordFull> => createTune(data),
|
||||
getTune: (tuneId: string): Promise<TunesRecordFull | null> => getTune(tuneId),
|
||||
searchTunes: (search?: string): Promise<TunesRecordFull[]> => searchTunes(search),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -1,33 +1,35 @@
|
|||
import { notification } from 'antd';
|
||||
import Pako from 'pako';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import {
|
||||
ID,
|
||||
Models,
|
||||
Permission,
|
||||
Role,
|
||||
} from 'appwrite';
|
||||
import { storage } from '../appwrite';
|
||||
import { fetchEnv } from '../utils/env';
|
||||
import { API_URL } from '../pocketbase';
|
||||
import { Collections } from '../@types/pocketbase-types';
|
||||
|
||||
const PUBLIC_PATH = 'public';
|
||||
const INI_PATH = `${PUBLIC_PATH}/ini`;
|
||||
export const CDN_URL = fetchEnv('VITE_CDN_URL');
|
||||
|
||||
export type ServerFile = Models.File;
|
||||
|
||||
const genericError = (error: Error) => notification.error({ message: 'Storage Error', description: error.message });
|
||||
|
||||
const fetchFromServer = async (path: string): Promise<ArrayBuffer> => {
|
||||
const response = await fetch(`${CDN_URL}/${path}`);
|
||||
return Promise.resolve(response.arrayBuffer());
|
||||
};
|
||||
|
||||
const fetchFileFromServer = async (recordId: string, filename: string, inflate = true): Promise<ArrayBuffer> => {
|
||||
const response = await fetch(`${API_URL}/api/files/${Collections.Tunes}/${recordId}/${filename}`);
|
||||
|
||||
if (inflate) {
|
||||
return Pako.inflate(new Uint8Array(await response.arrayBuffer()));
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
|
||||
const useServerStorage = () => {
|
||||
const getINIFile = async (signature: string) => {
|
||||
const { version, baseVersion } = /.+?(?<version>(?<baseVersion>\d+)(-\w+)*)/.exec(signature)?.groups || { version: null, baseVersion: null };
|
||||
|
||||
try {
|
||||
return fetchFromServer(`${INI_PATH}/${version}.ini.gz`);
|
||||
return Pako.inflate(new Uint8Array(await fetchFromServer(`${INI_PATH}/${version}.ini.gz`)));
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
|
@ -53,74 +55,9 @@ const useServerStorage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const removeFile = async (bucketId: string, fileId: string) => {
|
||||
try {
|
||||
await storage.deleteFile(bucketId, fileId);
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (userId: string, bucketId: string, file: File) => {
|
||||
try {
|
||||
const createdFile = await storage.createFile(
|
||||
bucketId,
|
||||
ID.unique(),
|
||||
file,
|
||||
[
|
||||
Permission.read(Role.any()),
|
||||
Permission.write(Role.user(userId, 'verified')),
|
||||
],
|
||||
);
|
||||
|
||||
return Promise.resolve(createdFile);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getFile = async (id: string, bucketId: string) => {
|
||||
try {
|
||||
const file = await storage.getFile(bucketId, id);
|
||||
|
||||
return Promise.resolve(file);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileForDownload = async (id: string, bucketId: string) => {
|
||||
try {
|
||||
const file = storage.getFileView(bucketId, id);
|
||||
const response = await fetch(file.href);
|
||||
|
||||
return Promise.resolve(response.arrayBuffer());
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getFile: (id: string, bucketId: string): Promise<Models.File> => getFile(id, bucketId),
|
||||
getINIFile: (signature: string): Promise<ArrayBuffer> => getINIFile(signature),
|
||||
removeFile: (bucketId: string, fileId: string): Promise<void> => removeFile(bucketId, fileId),
|
||||
getFileForDownload: (bucketId: string, fileId: string): Promise<ArrayBuffer> => getFileForDownload(bucketId, fileId),
|
||||
uploadFile: (userId: string, bucketId: string, file: File): Promise<ServerFile> => uploadFile(userId, bucketId, file),
|
||||
fetchFileFromServer: (recordId: string, filename: string): Promise<ArrayBuffer> => fetchFileFromServer(recordId, filename),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
CopyOutlined,
|
||||
StarOutlined,
|
||||
ArrowRightOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
useCallback,
|
||||
|
@ -29,75 +28,77 @@ import useDb from '../hooks/useDb';
|
|||
import { Routes } from '../routes';
|
||||
import { buildFullUrl } from '../utils/url';
|
||||
import { aspirationMapper } from '../utils/tune/mappers';
|
||||
import { TuneDbDocument } from '../types/dbData';
|
||||
import {
|
||||
copyToClipboard,
|
||||
isClipboardSupported,
|
||||
} from '../utils/clipboard';
|
||||
import { ProfilesRecord } from '../@types/pocketbase-types';
|
||||
import { isEscape } from '../utils/keyboard/shortcuts';
|
||||
import { TunesRecordFull } from '../types/dbData';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface ListUsersResponse {
|
||||
users: [{
|
||||
id: string;
|
||||
name: string;
|
||||
}];
|
||||
}
|
||||
|
||||
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
|
||||
|
||||
const Hub = () => {
|
||||
const { xs } = useBreakpoint();
|
||||
const { searchTunes, listUsers } = useDb();
|
||||
const { searchTunes } = useDb();
|
||||
const navigate = useNavigate();
|
||||
const [dataSource, setDataSource] = useState<{}[]>([]); // TODO: fix this type
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const searchRef = useRef<InputRef | null>(null);
|
||||
|
||||
const loadData = debounce(async (searchText?: string) => {
|
||||
setIsLoading(true);
|
||||
const list = await searchTunes(searchText);
|
||||
// TODO: create `unpublishedTunes` collection for this
|
||||
const filtered = list.documents.filter((tune) => !!tune.vehicleName);
|
||||
|
||||
// set initial list
|
||||
setDataSource(filtered.map((tune) => ({
|
||||
setDataSource(list.map((tune) => ({
|
||||
...tune,
|
||||
key: tune.tuneId,
|
||||
year: tune.year,
|
||||
author: <LoadingOutlined spin />,
|
||||
author: (tune['@expand'] as { userProfile: ProfilesRecord }).userProfile.username,
|
||||
displacement: `${tune.displacement}l`,
|
||||
aspiration: aspirationMapper[tune.aspiration],
|
||||
publishedAt: new Date(tune.$updatedAt).toLocaleString(),
|
||||
created: new Date(tune.created).toLocaleString(),
|
||||
stars: 0,
|
||||
})));
|
||||
setIsLoading(false);
|
||||
|
||||
// update list with users
|
||||
const userList: ListUsersResponse = JSON.parse((await listUsers(filtered.map((tune) => tune.userId))).response);
|
||||
setDataSource((prev) => prev.map((item: any) => ({
|
||||
...item,
|
||||
author: userList.users.find((el) => el.id === item.userId)?.name,
|
||||
})));
|
||||
}, 300);
|
||||
|
||||
const debounceLoadData = useCallback((value: string) => loadData(value), [loadData]);
|
||||
const debounceLoadData = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
loadData(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleGlobalKeyboard = useCallback((e: KeyboardEvent) => {
|
||||
if (isEscape(e)) {
|
||||
setSearchQuery('');
|
||||
loadData();
|
||||
}
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyboard);
|
||||
// searchRef.current?.focus(); // autofocus
|
||||
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyboard);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const columns: ColumnsType<any> = [
|
||||
{
|
||||
title: 'Tunes',
|
||||
render: (tune: TuneDbDocument) => (
|
||||
render: (tune: TunesRecordFull) => (
|
||||
<>
|
||||
<Title level={5}>{tune.vehicleName}</Title>
|
||||
<Space direction="vertical">
|
||||
<Text type="secondary">{tune.author}, {tune.publishedAt}</Text>
|
||||
<Text type="secondary">{tune.author}, {tune.created}</Text>
|
||||
<Text>{tune.engineMake}, {tune.engineCode}, {tune.displacement}, {tune.cylindersCount} cylinders, {tune.aspiration}</Text>
|
||||
<Text code>{tune.signature}</Text>
|
||||
</Space>
|
||||
|
@ -155,8 +156,8 @@ const Hub = () => {
|
|||
},
|
||||
{
|
||||
title: 'Published',
|
||||
dataIndex: 'publishedAt',
|
||||
key: 'publishedAt',
|
||||
dataIndex: 'created',
|
||||
key: 'created',
|
||||
responsive: ['sm'],
|
||||
},
|
||||
{
|
||||
|
@ -186,6 +187,7 @@ const Hub = () => {
|
|||
tabIndex={1}
|
||||
ref={searchRef}
|
||||
style={{ marginBottom: 10, height: 40 }}
|
||||
value={searchQuery}
|
||||
placeholder="Search..."
|
||||
onChange={({ target }) => debounceLoadData(target.value)}
|
||||
allowClear
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import {
|
||||
|
@ -25,7 +21,6 @@ import {
|
|||
import Loader from '../components/Loader';
|
||||
import { Routes } from '../routes';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import useDb from '../hooks/useDb';
|
||||
|
||||
const { Item } = Form;
|
||||
const rowProps = { gutter: 10 };
|
||||
|
@ -35,35 +30,15 @@ const mapStateToProps = (state: AppState) => ({
|
|||
tuneData: state.tuneData,
|
||||
});
|
||||
|
||||
interface GetUserResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
tunes: [];
|
||||
}
|
||||
|
||||
const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useAuth();
|
||||
const { getUser } = useDb();
|
||||
const [author, setAuthor] = useState<string>();
|
||||
|
||||
const goToEdit = () => navigate(generatePath(Routes.UPLOAD_WITH_TUNE_ID, {
|
||||
tuneId: tuneData.tuneId,
|
||||
}));
|
||||
|
||||
const loadData = async () => {
|
||||
const authorData: GetUserResponse = JSON.parse((await getUser(tuneData.userId)).response);
|
||||
setAuthor(authorData.name);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tuneData?.userId) {
|
||||
loadData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tuneData]);
|
||||
|
||||
const canManage = tuneData?.userId === currentUser?.$id;
|
||||
const canManage = currentUser && tuneData && currentUser.id === tuneData.user;
|
||||
|
||||
const manageSection = (
|
||||
<>
|
||||
|
@ -94,7 +69,7 @@ const Info = ({ tuneData }: { tuneData: TuneDataState }) => {
|
|||
<Row {...rowProps}>
|
||||
<Col span={24} sm={24}>
|
||||
<Item>
|
||||
<Input value={author || 'loading...'} addonBefore="Author" />
|
||||
<Input value={tuneData['@expand'].userProfile.username} addonBefore="Author" />
|
||||
</Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
|
@ -30,8 +30,9 @@ import {
|
|||
EditOutlined,
|
||||
CheckOutlined,
|
||||
SendOutlined,
|
||||
GlobalOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { INI } from '@hyper-tuner/ini';
|
||||
import { UploadRequestOption } from 'rc-upload/lib/interface';
|
||||
import { UploadFile } from 'antd/lib/upload/interface';
|
||||
|
@ -40,11 +41,13 @@ import {
|
|||
useMatch,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import Pako from 'pako';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
emailNotVerified,
|
||||
restrictedPage,
|
||||
usernameNotSet,
|
||||
} from './auth/notifications';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Routes } from '../routes';
|
||||
|
@ -52,16 +55,20 @@ import TuneParser from '../utils/tune/TuneParser';
|
|||
import TriggerLogsParser from '../utils/logs/TriggerLogsParser';
|
||||
import LogParser from '../utils/logs/LogParser';
|
||||
import useDb from '../hooks/useDb';
|
||||
import useServerStorage, { ServerFile } from '../hooks/useServerStorage';
|
||||
import useServerStorage from '../hooks/useServerStorage';
|
||||
import { buildFullUrl } from '../utils/url';
|
||||
import Loader from '../components/Loader';
|
||||
import {
|
||||
requiredTextRules,
|
||||
requiredRules,
|
||||
} from '../utils/form';
|
||||
import { TuneDbDocument } from '../types/dbData';
|
||||
import { aspirationMapper } from '../utils/tune/mappers';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { TunesRecord } from '../@types/pocketbase-types';
|
||||
import {
|
||||
TunesRecordFull,
|
||||
TunesRecordPartial,
|
||||
} from '../types/dbData';
|
||||
|
||||
const { Item, useForm } = Form;
|
||||
|
||||
|
@ -78,7 +85,6 @@ interface ValidationResult {
|
|||
}
|
||||
|
||||
type ValidateFile = (file: File) => Promise<ValidationResult>;
|
||||
type UploadDone = (fileCreated: ServerFile, file: File) => void;
|
||||
|
||||
const rowProps = { gutter: 10 };
|
||||
const colProps = { span: 24, sm: 12 };
|
||||
|
@ -97,6 +103,8 @@ const iniIcon = () => <FileTextOutlined />;
|
|||
const tunePath = (tuneId: string) => generatePath(Routes.TUNE_TUNE, { tuneId });
|
||||
const tuneParser = new TuneParser();
|
||||
|
||||
const bufferToFile = (buffer: ArrayBuffer, name: string) => new File([buffer], name);
|
||||
|
||||
const UploadPage = () => {
|
||||
const [form] = useForm();
|
||||
const routeMatch = useMatch(Routes.UPLOAD_WITH_TUNE_ID);
|
||||
|
@ -104,39 +112,42 @@ const UploadPage = () => {
|
|||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isTuneLoading, setTuneIsLoading] = useState(true);
|
||||
const [newTuneId, setNewTuneId] = useState<string>();
|
||||
const [tuneDocumentId, setTuneDocumentId] = useState<string>();
|
||||
const [isUserAuthorized, setIsUserAuthorized] = useState(false);
|
||||
const [shareUrl, setShareUrl] = useState<string>();
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [readme, setReadme] = useState(defaultReadme);
|
||||
const [existingTune, setExistingTune] = useState<TuneDbDocument>();
|
||||
const [existingTune, setExistingTune] = useState<TunesRecordFull>();
|
||||
|
||||
const [defaultTuneFileList, setDefaultTuneFileList] = useState<UploadFile[]>([]);
|
||||
const [defaultLogFilesList, setDefaultLogFilesList] = useState<UploadFile[]>([]);
|
||||
const [defaultToothLogFilesList, setDefaultToothLogFilesList] = useState<UploadFile[]>([]);
|
||||
const [defaultCustomIniFileList, setDefaultCustomIniFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
const [tuneFileId, setTuneFileId] = useState<string | null>(null);
|
||||
const [logFileIds, setLogFileIds] = useState<Map<string, string>>(new Map());
|
||||
const [toothLogFileIds, setToothLogFileIds] = useState<Map<string, string>>(new Map());
|
||||
const [customIniFileId, setCustomIniFileId] = useState<string | null>(null);
|
||||
const [tuneFile, setTuneFile] = useState<File>();
|
||||
const [customIniFile, setCustomIniFile] = useState<File>();
|
||||
const [logFiles, setLogFiles] = useState<File[]>([]);
|
||||
const [toothLogFiles, setToothLogFiles] = useState<File[]>([]);
|
||||
|
||||
const shareSupported = 'share' in navigator;
|
||||
const { currentUser } = useAuth();
|
||||
const { currentUser, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { removeFile, uploadFile, getFile } = useServerStorage();
|
||||
const { createTune, getBucketId, updateTune, getTune } = useDb();
|
||||
const { fetchFileFromServer } = useServerStorage();
|
||||
const { createTune, updateTune, getTune } = useDb();
|
||||
|
||||
const fetchFile = async (tuneId: string, fileName: string) => bufferToFile(await fetchFileFromServer(tuneId, fileName), fileName);
|
||||
|
||||
const noop = () => { };
|
||||
|
||||
const removeFilenameSuffix = (filename: string) => filename.replace(/(.+)(_\w{10})(\.\w+)$/, '$1$3');
|
||||
|
||||
const goToNewTune = () => navigate(generatePath(Routes.TUNE_TUNE, {
|
||||
tuneId: newTuneId!,
|
||||
}));
|
||||
|
||||
const genericError = (error: Error) => notification.error({ message: 'Error', description: error.message });
|
||||
|
||||
const publishTune = async (values: any) => {
|
||||
setIsLoading(true);
|
||||
|
||||
/* eslint-disable prefer-destructuring */
|
||||
const vehicleName = values.vehicleName.trim();
|
||||
const engineMake = values.engineMake.trim();
|
||||
|
@ -151,10 +162,36 @@ const UploadPage = () => {
|
|||
const year = values.year || null;
|
||||
const hp = values.hp || null;
|
||||
const stockHp = values.stockHp || null;
|
||||
const visibility = values.visibility;
|
||||
/* eslint-enable prefer-destructuring */
|
||||
|
||||
setIsLoading(true);
|
||||
await updateTune(tuneDocumentId!, {
|
||||
const compressedTuneFile = bufferToFile(
|
||||
Pako.deflate(await tuneFile!.arrayBuffer()),
|
||||
removeFilenameSuffix(tuneFile!.name),
|
||||
);
|
||||
|
||||
const compressedCustomIniFile = customIniFile ? bufferToFile(
|
||||
Pako.deflate(await customIniFile!.arrayBuffer()),
|
||||
removeFilenameSuffix(customIniFile!.name),
|
||||
) : null;
|
||||
|
||||
const compressedLogFiles = await Promise.all(logFiles.map(async (file) => bufferToFile(
|
||||
Pako.deflate(await file.arrayBuffer()),
|
||||
removeFilenameSuffix(file.name),
|
||||
)));
|
||||
|
||||
const compressedToothLogFiles = await Promise.all(toothLogFiles.map(async (file) => bufferToFile(
|
||||
Pako.deflate(await file.arrayBuffer()),
|
||||
removeFilenameSuffix(file.name),
|
||||
)));
|
||||
|
||||
const { signature } = tuneParser.parse(await tuneFile!.arrayBuffer()).getTune().details;
|
||||
|
||||
const newData: TunesRecord = {
|
||||
user: currentUser!.id,
|
||||
userProfile: currentUser!.profile!.id,
|
||||
tuneId: newTuneId!,
|
||||
signature,
|
||||
vehicleName,
|
||||
engineMake,
|
||||
engineCode,
|
||||
|
@ -169,8 +206,13 @@ const UploadPage = () => {
|
|||
hp,
|
||||
stockHp,
|
||||
readme: readme?.trim(),
|
||||
visibility,
|
||||
tuneFile: compressedTuneFile as unknown as string,
|
||||
customIniFile: compressedCustomIniFile as unknown as string,
|
||||
logFiles: compressedLogFiles as unknown as string[],
|
||||
toothLogFiles: compressedToothLogFiles as unknown as string[],
|
||||
textSearch: [
|
||||
existingTune?.signature,
|
||||
signature,
|
||||
vehicleName,
|
||||
engineMake,
|
||||
engineCode,
|
||||
|
@ -182,7 +224,36 @@ const UploadPage = () => {
|
|||
].filter((field) => field !== null && `${field}`.length > 1)
|
||||
.join(' ')
|
||||
.replace(/[^A-z.\-\d ]/g, ''),
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
Object.keys(newData).forEach((key) => {
|
||||
const value = (newData as any)[key];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((file: File) => {
|
||||
formData.append(key, file);
|
||||
});
|
||||
} else {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
if (existingTune) {
|
||||
// clear old multi files first
|
||||
if (logFiles.length > 0 || toothLogFiles.length > 0) {
|
||||
const tempFormData = new FormData();
|
||||
tempFormData.append('logFiles', '');
|
||||
tempFormData.append('toothLogFiles', '');
|
||||
await updateTune(existingTune.id, tempFormData as unknown as TunesRecord);
|
||||
}
|
||||
|
||||
await updateTune(existingTune.id, formData as unknown as TunesRecord);
|
||||
} else {
|
||||
await createTune(formData as unknown as TunesRecord);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsPublished(true);
|
||||
};
|
||||
|
@ -198,7 +269,7 @@ const UploadPage = () => {
|
|||
}), { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
const upload = async (options: UploadRequestOption, done: UploadDone, validate: ValidateFile) => {
|
||||
const upload = async (options: UploadRequestOption, done: (file: File) => void, validate: ValidateFile) => {
|
||||
const { onError, onSuccess, file } = options;
|
||||
|
||||
const validation = await validate(file as File);
|
||||
|
@ -211,54 +282,15 @@ const UploadPage = () => {
|
|||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pako = await import('pako');
|
||||
const buffer = await (file as File).arrayBuffer();
|
||||
const compressed = pako.deflate(new Uint8Array(buffer));
|
||||
const bucketId = await getBucketId(currentUser!.$id);
|
||||
const fileCreated: ServerFile = await uploadFile(currentUser!.$id, bucketId, new File([compressed], (file as File).name));
|
||||
|
||||
done(fileCreated, file as File);
|
||||
onSuccess!(null);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error('Upload error:', error);
|
||||
notification.error({ message: 'Upload error', description: (error as Error).message });
|
||||
onError!(error as Error);
|
||||
|
||||
return false;
|
||||
}
|
||||
done(file as File);
|
||||
onSuccess!(null);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadTune = async (options: UploadRequestOption) => {
|
||||
upload(options, async (fileCreated: ServerFile, file: File) => {
|
||||
const { signature } = tuneParser.parse(await file.arrayBuffer()).getTune().details;
|
||||
|
||||
if (tuneDocumentId) {
|
||||
await updateTune(tuneDocumentId, {
|
||||
signature,
|
||||
tuneFileId: fileCreated.$id,
|
||||
});
|
||||
} else {
|
||||
const document = await createTune({
|
||||
userId: currentUser!.$id,
|
||||
tuneId: newTuneId!,
|
||||
signature,
|
||||
tuneFileId: fileCreated.$id,
|
||||
vehicleName: '',
|
||||
displacement: 0,
|
||||
cylindersCount: 0,
|
||||
engineMake: '',
|
||||
engineCode: '',
|
||||
aspiration: 'na',
|
||||
readme: '',
|
||||
});
|
||||
setTuneDocumentId(document.$id);
|
||||
}
|
||||
|
||||
setTuneFileId(fileCreated.$id);
|
||||
upload(options, async (file) => {
|
||||
setTuneFile(file);
|
||||
}, async (file) => {
|
||||
const { result, message } = await validateSize(file);
|
||||
if (!result) {
|
||||
|
@ -273,10 +305,8 @@ const UploadPage = () => {
|
|||
};
|
||||
|
||||
const uploadLogs = async (options: UploadRequestOption) => {
|
||||
upload(options, async (fileCreated) => {
|
||||
const newValues = new Map(logFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
|
||||
await updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
|
||||
setLogFileIds(newValues);
|
||||
upload(options, async (file) => {
|
||||
setLogFiles((prev) => [...prev, file]);
|
||||
}, async (file) => {
|
||||
const { result, message } = await validateSize(file);
|
||||
if (!result) {
|
||||
|
@ -308,10 +338,8 @@ const UploadPage = () => {
|
|||
};
|
||||
|
||||
const uploadToothLogs = async (options: UploadRequestOption) => {
|
||||
upload(options, async (fileCreated) => {
|
||||
const newValues = new Map(toothLogFileIds.set((options.file as UploadFile).uid, fileCreated.$id));
|
||||
await updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
|
||||
setToothLogFileIds(newValues);
|
||||
upload(options, async (file) => {
|
||||
setToothLogFiles((prev) => [...prev, file]);
|
||||
}, async (file) => {
|
||||
const { result, message } = await validateSize(file);
|
||||
if (!result) {
|
||||
|
@ -328,9 +356,8 @@ const UploadPage = () => {
|
|||
};
|
||||
|
||||
const uploadCustomIni = async (options: UploadRequestOption) => {
|
||||
upload(options, async (fileCreated) => {
|
||||
await updateTune(tuneDocumentId!, { customIniFileId: fileCreated.$id });
|
||||
setCustomIniFileId(fileCreated.$id);
|
||||
upload(options, async (file) => {
|
||||
setCustomIniFile(file);
|
||||
}, async (file) => {
|
||||
const { result, message } = await validateSize(file);
|
||||
if (!result) {
|
||||
|
@ -354,54 +381,20 @@ const UploadPage = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const removeFileFromStorage = async (fileId: string | null): Promise<boolean> => {
|
||||
if (!fileId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await removeFile(await getBucketId(currentUser!.$id), fileId);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const removeTuneFile = async () => {
|
||||
if (!await removeFileFromStorage(tuneFileId!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateTune(tuneDocumentId!, { tuneFileId: null });
|
||||
setTuneFileId(null);
|
||||
setTuneFile(undefined);
|
||||
};
|
||||
|
||||
const removeLogFile = async (file: UploadFile) => {
|
||||
if (!await removeFileFromStorage(logFileIds.get(file.uid)!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
logFileIds.delete(file.uid);
|
||||
const newValues = new Map(logFileIds);
|
||||
setLogFileIds(newValues);
|
||||
updateTune(tuneDocumentId!, { logFileIds: Array.from(newValues.values()) });
|
||||
setLogFiles((prev) => prev.filter((f) => f.name !== file.name));
|
||||
};
|
||||
|
||||
const removeToothLogFile = async (file: UploadFile) => {
|
||||
if (!await removeFileFromStorage(toothLogFileIds.get(file.uid)!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
toothLogFileIds.delete(file.uid);
|
||||
const newValues = new Map(toothLogFileIds);
|
||||
setToothLogFileIds(newValues);
|
||||
updateTune(tuneDocumentId!, { toothLogFileIds: Array.from(newValues.values()) });
|
||||
setToothLogFiles((prev) => prev.filter((f) => f.name !== file.name));
|
||||
};
|
||||
|
||||
const removeCustomIniFile = async (file: UploadFile) => {
|
||||
if (!await removeFileFromStorage(customIniFileId!)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateTune(tuneDocumentId!, { customIniFileId: null });
|
||||
setCustomIniFileId(null);
|
||||
setCustomIniFile(undefined);
|
||||
};
|
||||
|
||||
const loadExistingTune = useCallback(async (currentTuneId: string) => {
|
||||
|
@ -411,7 +404,7 @@ const UploadPage = () => {
|
|||
const oldTune = await getTune(currentTuneId);
|
||||
if (oldTune) {
|
||||
// this is someone elses tune
|
||||
if (oldTune.userId !== currentUser?.$id) {
|
||||
if (oldTune.user !== currentUser?.id) {
|
||||
navigateToNewTuneId();
|
||||
return;
|
||||
}
|
||||
|
@ -419,54 +412,54 @@ const UploadPage = () => {
|
|||
setExistingTune(oldTune);
|
||||
form.setFieldsValue(oldTune);
|
||||
setIsEditMode(true);
|
||||
setTuneDocumentId(oldTune.$id);
|
||||
setReadme(oldTune.readme!);
|
||||
|
||||
if (oldTune.tuneFileId) {
|
||||
const file = await getFile(oldTune.tuneFileId, await getBucketId(currentUser!.$id));
|
||||
setTuneFileId(oldTune.tuneFileId);
|
||||
if (oldTune.tuneFile) {
|
||||
setTuneFile(await fetchFile(oldTune.id, oldTune.tuneFile));
|
||||
setDefaultTuneFileList([{
|
||||
uid: file.$id,
|
||||
name: file.name,
|
||||
uid: oldTune.tuneFile,
|
||||
name: oldTune.tuneFile,
|
||||
status: 'done',
|
||||
}]);
|
||||
}
|
||||
|
||||
if (oldTune.customIniFileId) {
|
||||
const file = await getFile(oldTune.customIniFileId, await getBucketId(currentUser!.$id));
|
||||
setCustomIniFileId(oldTune.customIniFileId);
|
||||
if (oldTune.customIniFile) {
|
||||
setCustomIniFile(await fetchFile(oldTune.id, oldTune.customIniFile));
|
||||
setDefaultCustomIniFileList([{
|
||||
uid: file.$id,
|
||||
name: file.name,
|
||||
uid: oldTune.customIniFile,
|
||||
name: oldTune.customIniFile,
|
||||
status: 'done',
|
||||
}]);
|
||||
}
|
||||
|
||||
oldTune.logFileIds?.forEach(async (fileId: string) => {
|
||||
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
|
||||
setLogFileIds((prev) => new Map(prev).set(fileId, fileId));
|
||||
const tempLogFiles: File[] = [];
|
||||
oldTune.logFiles?.forEach(async (fileName: string) => {
|
||||
tempLogFiles.push(await fetchFile(oldTune.id, fileName));
|
||||
setDefaultLogFilesList((prev) => [...prev, {
|
||||
uid: file.$id,
|
||||
name: file.name,
|
||||
uid: fileName,
|
||||
name: fileName,
|
||||
status: 'done',
|
||||
}]);
|
||||
});
|
||||
setLogFiles(tempLogFiles);
|
||||
|
||||
oldTune.toothLogFileIds?.forEach(async (fileId: string) => {
|
||||
const file = await getFile(fileId, await getBucketId(currentUser!.$id));
|
||||
setToothLogFileIds((prev) => new Map(prev).set(fileId, fileId));
|
||||
const tempToothLogFiles: File[] = [];
|
||||
oldTune.toothLogFiles?.forEach(async (fileName: string) => {
|
||||
tempToothLogFiles.push(await fetchFile(oldTune.id, fileName));
|
||||
setDefaultToothLogFilesList((prev) => [...prev, {
|
||||
uid: file.$id,
|
||||
name: file.name,
|
||||
uid: fileName,
|
||||
name: fileName,
|
||||
status: 'done',
|
||||
}]);
|
||||
});
|
||||
setToothLogFiles(tempToothLogFiles);
|
||||
|
||||
} else {
|
||||
// reset state
|
||||
form.resetFields();
|
||||
setReadme(defaultReadme);
|
||||
setTuneFileId(null);
|
||||
setCustomIniFileId(null);
|
||||
setTuneFile(undefined);
|
||||
setCustomIniFile(undefined);
|
||||
setDefaultTuneFileList([]);
|
||||
setDefaultLogFilesList([]);
|
||||
setDefaultToothLogFilesList([]);
|
||||
|
@ -488,29 +481,40 @@ const UploadPage = () => {
|
|||
}, [loadExistingTune, navigateToNewTuneId, routeMatch?.params.tuneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) {
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currentUser.emailVerification) {
|
||||
emailNotVerified();
|
||||
refreshUser().then((user) => {
|
||||
if (user === null) {
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
setIsUserAuthorized(true);
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
genericError(error as Error);
|
||||
}
|
||||
|
||||
prepareData();
|
||||
}, [currentUser, navigate, prepareData, routeMatch?.params.tuneId]);
|
||||
if (!user) {
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.verified) {
|
||||
emailNotVerified();
|
||||
navigate(Routes.PROFILE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((user.profile?.username?.length || 0) === 0) {
|
||||
usernameNotSet();
|
||||
navigate(Routes.PROFILE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUserAuthorized(true);
|
||||
prepareData();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [routeMatch?.params.tuneId]);
|
||||
|
||||
const uploadButton = (
|
||||
<Space direction="vertical">
|
||||
|
@ -518,10 +522,39 @@ const UploadPage = () => {
|
|||
</Space>
|
||||
);
|
||||
|
||||
const shareSection = (
|
||||
const publishButton = (
|
||||
<Row style={{ marginTop: 10 }} {...rowProps}>
|
||||
<Col {...colProps}>
|
||||
<Item name="visibility" rules={requiredTextRules}>
|
||||
<Select disabled>
|
||||
<Select.Option value="public">
|
||||
<Space><GlobalOutlined />Public</Space>
|
||||
</Select.Option>
|
||||
<Select.Option value="unlisted">
|
||||
<Space><EyeOutlined />Unlisted</Space>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Item>
|
||||
</Col>
|
||||
<Col {...colProps}>
|
||||
<Item style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
loading={isLoading}
|
||||
htmlType="submit"
|
||||
icon={isEditMode ? <EditOutlined /> : <CheckOutlined />}
|
||||
>
|
||||
{isEditMode ? 'Update' : 'Publish'}
|
||||
</Button>
|
||||
</Item>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
|
||||
const openButton = (
|
||||
<>
|
||||
<Divider>Publish & Share</Divider>
|
||||
{isPublished && <Row>
|
||||
<Row>
|
||||
<Input
|
||||
style={{ width: `calc(100% - ${shareSupported ? 65 : 35}px)` }}
|
||||
value={shareUrl!}
|
||||
|
@ -537,30 +570,29 @@ const UploadPage = () => {
|
|||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Row>}
|
||||
</Row>
|
||||
<Row style={{ marginTop: 10 }}>
|
||||
<Item style={{ width: '100%' }}>
|
||||
{!isPublished ? <Button
|
||||
type="primary"
|
||||
block
|
||||
loading={isLoading}
|
||||
htmlType="submit"
|
||||
icon={isEditMode ? <EditOutlined /> : <CheckOutlined />}
|
||||
>
|
||||
{isEditMode ? 'Update' : 'Publish'}
|
||||
</Button> : <Button
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
onClick={goToNewTune}
|
||||
icon={<SendOutlined />}
|
||||
>
|
||||
Open
|
||||
</Button>}
|
||||
</Button>
|
||||
</Item>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
const shareSection = (
|
||||
<>
|
||||
<Divider>Publish & Share</Divider>
|
||||
{isPublished ? openButton : publishButton}
|
||||
</>
|
||||
);
|
||||
|
||||
const detailsSection = (
|
||||
<>
|
||||
<Divider>
|
||||
|
@ -711,7 +743,7 @@ const UploadPage = () => {
|
|||
defaultFileList={defaultLogFilesList}
|
||||
accept=".mlg,.csv,.msl"
|
||||
>
|
||||
{logFileIds.size < MaxFiles.LOG_FILES && uploadButton}
|
||||
{logFiles.length < MaxFiles.LOG_FILES && uploadButton}
|
||||
</Upload>
|
||||
<Divider>
|
||||
<Space>
|
||||
|
@ -731,7 +763,7 @@ const UploadPage = () => {
|
|||
defaultFileList={defaultToothLogFilesList}
|
||||
accept=".csv"
|
||||
>
|
||||
{toothLogFileIds.size < MaxFiles.TOOTH_LOG_FILES && uploadButton}
|
||||
{toothLogFiles.length < MaxFiles.TOOTH_LOG_FILES && uploadButton}
|
||||
</Upload>
|
||||
<Divider>
|
||||
<Space>
|
||||
|
@ -750,17 +782,13 @@ const UploadPage = () => {
|
|||
defaultFileList={defaultCustomIniFileList}
|
||||
accept=".ini"
|
||||
>
|
||||
{!customIniFileId && uploadButton}
|
||||
{!customIniFile && uploadButton}
|
||||
</Upload>
|
||||
{detailsSection}
|
||||
{shareUrl && tuneFileId && shareSection}
|
||||
{shareUrl && tuneFile && shareSection}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!isUserAuthorized || isTuneLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (isPublished) {
|
||||
return (
|
||||
<div className="small-container">
|
||||
|
@ -769,9 +797,28 @@ const UploadPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!isUserAuthorized || isTuneLoading) {
|
||||
return (
|
||||
<Form form={form}>
|
||||
<Loader />
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="small-container">
|
||||
<Form onFinish={publishTune} initialValues={{ readme }} form={form}>
|
||||
<Form
|
||||
initialValues={{
|
||||
aspiration: 'na',
|
||||
readme,
|
||||
visibility: 'public',
|
||||
cylindersCount: 4,
|
||||
displacement: 1.6,
|
||||
year: thisYear,
|
||||
} as TunesRecordPartial}
|
||||
form={form}
|
||||
onFinish={publishTune}
|
||||
>
|
||||
<Divider>
|
||||
<Space>
|
||||
Upload Tune
|
||||
|
@ -789,9 +836,9 @@ const UploadPage = () => {
|
|||
defaultFileList={defaultTuneFileList}
|
||||
accept=".msq"
|
||||
>
|
||||
{tuneFileId === null && uploadButton}
|
||||
{tuneFile === undefined && uploadButton}
|
||||
</Upload>
|
||||
{(tuneFileId || defaultTuneFileList.length > 0) && optionalSection}
|
||||
{(tuneFile || defaultTuneFileList.length > 0) && optionalSection}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import {
|
||||
useMatch,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import Loader from '../../components/Loader';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
@ -14,24 +14,17 @@ import {
|
|||
const EmailVerification = () => {
|
||||
const { confirmEmailVerification } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const secret = searchParams.get('secret');
|
||||
const routeMatch = useMatch(Routes.EMAIL_VERIFICATION);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && secret) {
|
||||
confirmEmailVerification(userId, secret)
|
||||
.then(() => emailVerificationSuccess())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
emailVerificationFailed(error);
|
||||
});
|
||||
} else {
|
||||
emailVerificationFailed(new Error('Invalid URL'));
|
||||
}
|
||||
confirmEmailVerification(routeMatch!.params.token!)
|
||||
.then(() => emailVerificationSuccess())
|
||||
.catch((error) => {
|
||||
emailVerificationFailed(error);
|
||||
});
|
||||
|
||||
navigate(Routes.HUB);
|
||||
});
|
||||
}, [confirmEmailVerification, navigate, routeMatch]);
|
||||
|
||||
return <Loader />;
|
||||
};
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
|
@ -16,162 +18,222 @@ import {
|
|||
GoogleOutlined,
|
||||
GithubOutlined,
|
||||
FacebookOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import {
|
||||
OAuthProviders,
|
||||
useAuth,
|
||||
} from '../../contexts/AuthContext';
|
||||
import { Routes } from '../../routes';
|
||||
import validateMessages from './validateMessages';
|
||||
import {
|
||||
emailNotVerified,
|
||||
logInFailed,
|
||||
logInSuccessful,
|
||||
magicLinkSent,
|
||||
signUpFailed,
|
||||
signUpSuccessful,
|
||||
} from './notifications';
|
||||
import {
|
||||
emailRules,
|
||||
requiredRules,
|
||||
} from '../../utils/form';
|
||||
import { buildRedirectUrl } from '../../utils/url';
|
||||
|
||||
const { Item } = Form;
|
||||
|
||||
const Login = () => {
|
||||
const [formMagicLink] = Form.useForm();
|
||||
export enum FormRoles {
|
||||
LOGIN = 'Login',
|
||||
SING_UP = 'Sign Up',
|
||||
}
|
||||
|
||||
const Login = ({ formRole }: { formRole: FormRoles }) => {
|
||||
const [formEmail] = Form.useForm();
|
||||
const isLogin = formRole === FormRoles.LOGIN;
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
|
||||
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
|
||||
const { login, googleAuth, githubAuth, facebookAuth, sendMagicLink } = useAuth();
|
||||
const [isOAuthLoading, setIsOAuthLoading] = useState(false);
|
||||
const [providersReady, setProvidersReady] = useState(false);
|
||||
const [googleUrl, setGoogleUrl] = useState<string | null>(null);
|
||||
const [githubUrl, setGithubUrl] = useState<string | null>(null);
|
||||
const [facebookUrl, setFacebookUrl] = useState<string | null>(null);
|
||||
const [providersStatuses, setProvidersStatuses] = useState<{ [key: string]: boolean }>({
|
||||
google: false,
|
||||
github: false,
|
||||
facebook: false,
|
||||
});
|
||||
|
||||
const { login, signUp, listAuthMethods } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
|
||||
const isAnythingLoading = isEmailLoading || isOAuthLoading;
|
||||
const redirectAfterLogin = useCallback(() => navigate(Routes.HUB), [navigate]);
|
||||
|
||||
const googleLogin = useCallback(async () => {
|
||||
setIsGoogleLoading(true);
|
||||
try {
|
||||
await googleAuth();
|
||||
} catch (error) {
|
||||
logInFailed(error as Error);
|
||||
}
|
||||
}, [googleAuth]);
|
||||
|
||||
const githubLogin = useCallback(async () => {
|
||||
setIsGithubLoading(true);
|
||||
try {
|
||||
await githubAuth();
|
||||
} catch (error) {
|
||||
logInFailed(error as Error);
|
||||
}
|
||||
}, [githubAuth]);
|
||||
|
||||
const facebookLogin = async () => {
|
||||
setIsFacebookLoading(true);
|
||||
try {
|
||||
await facebookAuth();
|
||||
} catch (error) {
|
||||
logInFailed(error as Error);
|
||||
}
|
||||
};
|
||||
const isOauthEnabled = Object.values(providersStatuses).includes(true);
|
||||
|
||||
const emailLogin = async ({ email, password }: { email: string, password: string }) => {
|
||||
setIsEmailLoading(true);
|
||||
try {
|
||||
const user = await login(email, password);
|
||||
logInSuccessful();
|
||||
if (!user.emailVerification) {
|
||||
|
||||
if (!user.verified) {
|
||||
emailNotVerified();
|
||||
}
|
||||
if (!user.name) {
|
||||
navigate(Routes.PROFILE);
|
||||
}
|
||||
|
||||
redirectAfterLogin();
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
logInFailed(error as Error);
|
||||
formMagicLink.resetFields();
|
||||
formEmail.resetFields();
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const magicLinkLogin = async ({ email }: { email: string }) => {
|
||||
setIsMagicLinkLoading(true);
|
||||
const emailSignUp = async ({ email, password }: { email: string, password: string }) => {
|
||||
setIsEmailLoading(true);
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
magicLinkSent();
|
||||
const user = await signUp(email, password);
|
||||
signUpSuccessful();
|
||||
|
||||
if (!user.verified) {
|
||||
emailNotVerified();
|
||||
}
|
||||
|
||||
navigate(Routes.HUB);
|
||||
} catch (error) {
|
||||
logInFailed(error as Error);
|
||||
} finally {
|
||||
setIsMagicLinkLoading(false);
|
||||
formMagicLink.resetFields();
|
||||
signUpFailed(error as Error);
|
||||
formEmail.resetFields();
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<Divider>Log In</Divider>
|
||||
const oauthMethods: { [provider: string]: { label: string, icon: ReactNode, onClick: () => void } } = {
|
||||
google: {
|
||||
label: 'Google',
|
||||
icon: <GoogleOutlined />,
|
||||
onClick: () => {
|
||||
setIsOAuthLoading(true);
|
||||
window.location.href = googleUrl!;
|
||||
},
|
||||
},
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
icon: <GithubOutlined />,
|
||||
onClick: () => {
|
||||
setIsOAuthLoading(true);
|
||||
window.location.href = githubUrl!;
|
||||
},
|
||||
},
|
||||
facebook: {
|
||||
label: 'Facebook',
|
||||
icon: <FacebookOutlined />,
|
||||
onClick: () => {
|
||||
setIsOAuthLoading(true);
|
||||
window.location.href = facebookUrl!;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
listAuthMethods().then((methods) => {
|
||||
const { authProviders } = methods;
|
||||
window.localStorage.setItem('authProviders', JSON.stringify(authProviders));
|
||||
|
||||
authProviders.forEach((provider) => {
|
||||
if (provider.name) {
|
||||
setProvidersReady(true);
|
||||
}
|
||||
|
||||
switch (provider.name) {
|
||||
case OAuthProviders.GOOGLE:
|
||||
setProvidersStatuses((prevState) => ({ ...prevState, [provider.name]: true }));
|
||||
setGoogleUrl(`${provider.authUrl}${encodeURIComponent(buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider: provider.name }))}`);
|
||||
break;
|
||||
case OAuthProviders.GITHUB:
|
||||
setProvidersStatuses((prevState) => ({ ...prevState, [provider.name]: true }));
|
||||
setGithubUrl(`${provider.authUrl}${encodeURIComponent(buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider: provider.name }))}`);
|
||||
break;
|
||||
case OAuthProviders.FACEBOOK:
|
||||
setProvidersStatuses((prevState) => ({ ...prevState, [provider.name]: true }));
|
||||
setFacebookUrl(`${provider.authUrl}${encodeURIComponent(buildRedirectUrl(Routes.REDIRECT_PAGE_OAUTH_CALLBACK, { provider: provider.name }))}`);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider.name}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [listAuthMethods]);
|
||||
|
||||
const oauthSection = (
|
||||
isOauthEnabled && <>
|
||||
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
|
||||
<Button
|
||||
loading={isGoogleLoading}
|
||||
onClick={googleLogin}
|
||||
disabled={isAnythingLoading}
|
||||
>
|
||||
<GoogleOutlined />Google
|
||||
</Button>
|
||||
<Button
|
||||
loading={isGithubLoading}
|
||||
onClick={githubLogin}
|
||||
disabled={isAnythingLoading}
|
||||
>
|
||||
<GithubOutlined />GitHub
|
||||
</Button>
|
||||
<Button
|
||||
loading={isFacebookLoading}
|
||||
onClick={facebookLogin}
|
||||
disabled={isAnythingLoading}
|
||||
>
|
||||
<FacebookOutlined />Facebook
|
||||
</Button>
|
||||
{providersReady && Object.keys(oauthMethods).map((provider) => (
|
||||
providersStatuses[provider] && <Button
|
||||
key={provider}
|
||||
icon={oauthMethods[provider].icon}
|
||||
onClick={oauthMethods[provider].onClick}
|
||||
loading={isOAuthLoading}
|
||||
>
|
||||
{oauthMethods[provider].label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
|
||||
const bottomLinksLogin = (
|
||||
<>
|
||||
<Link to={Routes.SIGN_UP}>
|
||||
Sign Up
|
||||
</Link>
|
||||
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
|
||||
const bottomLinksSignUp = (
|
||||
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
|
||||
Log In
|
||||
</Link>
|
||||
);
|
||||
|
||||
const submitLogin = (
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
loading={isEmailLoading}
|
||||
disabled={isAnythingLoading}
|
||||
icon={<UnlockOutlined />}
|
||||
>
|
||||
Login using password
|
||||
</Button>
|
||||
);
|
||||
|
||||
const submitSignUp = (
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
loading={isEmailLoading}
|
||||
disabled={isAnythingLoading}
|
||||
icon={<UserAddOutlined />}
|
||||
>
|
||||
Sign Up using password
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<Divider>{formRole}</Divider>
|
||||
{providersReady && oauthSection}
|
||||
<Form
|
||||
onFinish={magicLinkLogin}
|
||||
validateMessages={validateMessages}
|
||||
form={formMagicLink}
|
||||
>
|
||||
<Item name="email" rules={emailRules} hasFeedback>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="Email"
|
||||
id="email-magic-link"
|
||||
autoComplete="email"
|
||||
disabled={isAnythingLoading}
|
||||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
loading={isMagicLinkLoading}
|
||||
disabled={isAnythingLoading}
|
||||
icon={<MailOutlined />}
|
||||
>
|
||||
Send me a Magic Link
|
||||
</Button>
|
||||
</Item>
|
||||
</Form>
|
||||
<Form
|
||||
onFinish={emailLogin}
|
||||
onFinish={isLogin ? emailLogin : emailSignUp}
|
||||
validateMessages={validateMessages}
|
||||
form={formEmail}
|
||||
>
|
||||
<Divider />
|
||||
<Item name="email" rules={emailRules} hasFeedback>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
|
@ -193,23 +255,9 @@ const Login = () => {
|
|||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
loading={isEmailLoading}
|
||||
disabled={isAnythingLoading}
|
||||
icon={<UnlockOutlined />}
|
||||
>
|
||||
Log in using password
|
||||
</Button>
|
||||
{isLogin ? submitLogin : submitSignUp}
|
||||
</Item>
|
||||
<Link to={Routes.SIGN_UP}>
|
||||
Sign Up
|
||||
</Link>
|
||||
<Link to={Routes.RESET_PASSWORD} style={{ float: 'right' }}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
{isLogin ? bottomLinksLogin : bottomLinksSignUp}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import {
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import Loader from '../../components/Loader';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Routes } from '../../routes';
|
||||
import {
|
||||
logInSuccessful,
|
||||
magicLinkConfirmationFailed,
|
||||
} from './notifications';
|
||||
|
||||
const MagicLinkConfirmation = () => {
|
||||
const { confirmMagicLink } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const secret = searchParams.get('secret');
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && secret) {
|
||||
confirmMagicLink(userId, secret)
|
||||
.then(() => logInSuccessful())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
magicLinkConfirmationFailed(error);
|
||||
});
|
||||
} else {
|
||||
magicLinkConfirmationFailed(new Error('Invalid URL'));
|
||||
}
|
||||
|
||||
navigate(Routes.HUB);
|
||||
});
|
||||
|
||||
return <Loader />;
|
||||
};
|
||||
|
||||
export default MagicLinkConfirmation;
|
|
@ -0,0 +1,39 @@
|
|||
import { useEffect } from 'react';
|
||||
import {
|
||||
useMatch,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import Loader from '../../components/Loader';
|
||||
import {
|
||||
AuthProviderInfo,
|
||||
OAuthProviders,
|
||||
useAuth,
|
||||
} from '../../contexts/AuthContext';
|
||||
import { Routes } from '../../routes';
|
||||
import { logInSuccessful } from './notifications';
|
||||
|
||||
const OauthCallback = () => {
|
||||
const { oAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const routeMatch = useMatch(Routes.OAUTH_CALLBACK);
|
||||
|
||||
useEffect(() => {
|
||||
const authProviders = JSON.parse(window.localStorage.getItem('authProviders') || '') as unknown as AuthProviderInfo[];
|
||||
|
||||
oAuth(
|
||||
routeMatch?.params.provider as OAuthProviders,
|
||||
searchParams.get('code')!,
|
||||
authProviders.find((provider) => provider.name === routeMatch?.params.provider)?.codeVerifier!,
|
||||
)
|
||||
.then(() => {
|
||||
logInSuccessful();
|
||||
navigate(Routes.HUB, { replace: true });
|
||||
});
|
||||
}, [navigate, oAuth, routeMatch, searchParams]);
|
||||
|
||||
return <Loader />;
|
||||
};
|
||||
|
||||
export default OauthCallback;
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
@ -11,12 +10,10 @@ import {
|
|||
Divider,
|
||||
Alert,
|
||||
Space,
|
||||
List,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
MailOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import validateMessages from './validateMessages';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
@ -26,42 +23,24 @@ import {
|
|||
emailVerificationSent,
|
||||
profileUpdateSuccess,
|
||||
profileUpdateFailed,
|
||||
passwordUpdateSuccess,
|
||||
passwordUpdateFailed,
|
||||
} from './notifications';
|
||||
import { Routes } from '../../routes';
|
||||
import {
|
||||
passwordRules,
|
||||
requiredRules,
|
||||
} from '../../utils/form';
|
||||
import { usernameRules } from '../../utils/form';
|
||||
|
||||
const { Item } = Form;
|
||||
|
||||
const MAX_LIST_SIZE = 10;
|
||||
|
||||
const parseLogEvent = (raw: string) => {
|
||||
const split = raw.split('.');
|
||||
return [split[0], split[2], split[4]].join(' ');
|
||||
};
|
||||
|
||||
const Profile = () => {
|
||||
const [formProfile] = Form.useForm();
|
||||
const [formPassword] = Form.useForm();
|
||||
const {
|
||||
currentUser,
|
||||
sendEmailVerification,
|
||||
updateUsername,
|
||||
updatePassword,
|
||||
getSessions,
|
||||
getLogs,
|
||||
refreshUser,
|
||||
} = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isVerificationSent, setIsVerificationSent] = useState(false);
|
||||
const [isSendingVerification, setIsSendingVerification] = useState(false);
|
||||
const [isProfileLoading, setIsProfileLoading] = useState(false);
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
const [sessions, setSessions] = useState<string[] | null>(null);
|
||||
const [logs, setLogs] = useState<string[] | null>(null);
|
||||
|
||||
const resendEmailVerification = async () => {
|
||||
setIsSendingVerification(true);
|
||||
|
@ -77,24 +56,12 @@ const Profile = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const fetchLogs = useCallback(async () => getLogs()
|
||||
.then((list) => setLogs(list.logs.slice(0, MAX_LIST_SIZE).map((log) => [
|
||||
new Date(log.time).toLocaleString(),
|
||||
parseLogEvent(log.event),
|
||||
log.clientName,
|
||||
log.clientEngineVersion,
|
||||
log.osName,
|
||||
log.deviceName,
|
||||
log.countryName,
|
||||
log.ip,
|
||||
].join(' | ')))), [getLogs]);
|
||||
|
||||
const onUpdateProfile = async ({ username }: { username: string }) => {
|
||||
setIsProfileLoading(true);
|
||||
try {
|
||||
await updateUsername(username);
|
||||
profileUpdateSuccess();
|
||||
fetchLogs();
|
||||
refreshUser();
|
||||
} catch (error) {
|
||||
profileUpdateFailed(error as Error);
|
||||
} finally {
|
||||
|
@ -102,59 +69,44 @@ const Profile = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onUpdatePassword = async ({ password, oldPassword }: { password: string, oldPassword: string }) => {
|
||||
setIsPasswordLoading(true);
|
||||
try {
|
||||
await updatePassword(password, oldPassword);
|
||||
passwordUpdateSuccess();
|
||||
fetchLogs();
|
||||
formPassword.resetFields();
|
||||
} catch (error) {
|
||||
passwordUpdateFailed(error as Error);
|
||||
} finally {
|
||||
setIsPasswordLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
getSessions()
|
||||
.then((list) => setSessions(list.sessions.slice(0, MAX_LIST_SIZE).map((ses) => [
|
||||
ses.clientName,
|
||||
ses.osName,
|
||||
ses.deviceName,
|
||||
ses.countryName,
|
||||
ses.ip,
|
||||
].join(' | '))));
|
||||
if (!currentUser) {
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
|
||||
fetchLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
}, [currentUser, fetchLogs, getLogs, getSessions, navigate]);
|
||||
refreshUser().then((user) => {
|
||||
if (currentUser === null || user === null) {
|
||||
restrictedPage();
|
||||
navigate(Routes.LOGIN);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-container">
|
||||
{!currentUser?.emailVerification && (<>
|
||||
<Divider>Email verification</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Alert message="Your email address is not verified!" type="error" showIcon />
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%' }}
|
||||
icon={<MailOutlined />}
|
||||
disabled={isVerificationSent}
|
||||
loading={isSendingVerification}
|
||||
onClick={resendEmailVerification}
|
||||
>
|
||||
Resend verification
|
||||
</Button>
|
||||
</Space>
|
||||
</>)}
|
||||
<Divider>Your Profile</Divider>
|
||||
<div className="auth-container">
|
||||
{!currentUser?.verified && (<>
|
||||
<Divider>Email verification</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Alert message="Your email address is not verified!" type="error" showIcon />
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ width: '100%' }}
|
||||
icon={<MailOutlined />}
|
||||
disabled={isVerificationSent}
|
||||
loading={isSendingVerification}
|
||||
onClick={resendEmailVerification}
|
||||
>
|
||||
Resend verification
|
||||
</Button>
|
||||
</Space>
|
||||
</>)}
|
||||
<Divider>Your Profile</Divider>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
{(currentUser?.profile?.username?.length || 0) === 0 && <Alert message="Remember to set your username!" type="error" showIcon />}
|
||||
<Form
|
||||
validateMessages={validateMessages}
|
||||
form={formProfile}
|
||||
|
@ -162,7 +114,7 @@ const Profile = () => {
|
|||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
value: currentUser?.name,
|
||||
value: currentUser?.profile?.username,
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
|
@ -172,7 +124,7 @@ const Profile = () => {
|
|||
>
|
||||
<Item
|
||||
name="username"
|
||||
rules={requiredRules}
|
||||
rules={usernameRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input
|
||||
|
@ -196,66 +148,8 @@ const Profile = () => {
|
|||
</Button>
|
||||
</Item>
|
||||
</Form>
|
||||
<Divider>Password</Divider>
|
||||
<Form
|
||||
validateMessages={validateMessages}
|
||||
form={formPassword}
|
||||
onFinish={onUpdatePassword}
|
||||
>
|
||||
<Item
|
||||
name="oldPassword"
|
||||
rules={requiredRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Old password"
|
||||
autoComplete="current-password"
|
||||
prefix={<LockOutlined />}
|
||||
/>
|
||||
</Item>
|
||||
<Item
|
||||
name="password"
|
||||
rules={passwordRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="New password"
|
||||
autoComplete="new-password"
|
||||
prefix={<LockOutlined />}
|
||||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
icon={<LockOutlined />}
|
||||
loading={isPasswordLoading}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div className="large-container">
|
||||
<Divider>Active sessions</Divider>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={sessions || []}
|
||||
renderItem={item => <List.Item>{item}</List.Item>}
|
||||
loading={sessions === null}
|
||||
/>
|
||||
<Divider>Audit logs</Divider>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={logs || []}
|
||||
renderItem={item => <List.Item>{item}</List.Item>}
|
||||
loading={logs === null}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ const ResetPassword = () => {
|
|||
navigate(Routes.LOGIN);
|
||||
} catch (error) {
|
||||
form.resetFields();
|
||||
console.warn(error);
|
||||
resetFailed(error as Error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
|
@ -5,14 +5,11 @@ import {
|
|||
Input,
|
||||
} from 'antd';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Link,
|
||||
useMatch,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Routes } from '../../routes';
|
||||
|
@ -28,33 +25,23 @@ const { Item } = Form;
|
|||
const ResetPasswordConfirmation = () => {
|
||||
const { confirmResetPassword } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const userId = searchParams.get('userId');
|
||||
const secret = searchParams.get('secret');
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const routeMatch = useMatch(Routes.RESET_PASSWORD_CONFIRMATION);
|
||||
|
||||
const changePassword = async ({ password }: { password: string }) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await confirmResetPassword(userId!, secret!, password);
|
||||
await confirmResetPassword(routeMatch!.params.token!, password);
|
||||
passwordUpdateSuccess();
|
||||
navigate(Routes.LOGIN);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
passwordUpdateFailed(error as Error);
|
||||
form.resetFields();
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !secret) {
|
||||
passwordUpdateFailed(new Error('Invalid URL'));
|
||||
navigate(Routes.HUB);
|
||||
}
|
||||
}, [navigate, secret, userId]);
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<Divider>Change password</Divider>
|
||||
|
|
|
@ -1,237 +0,0 @@
|
|||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Divider,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
MailOutlined,
|
||||
LockOutlined,
|
||||
UserOutlined,
|
||||
GoogleOutlined,
|
||||
GithubOutlined,
|
||||
FacebookOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { Routes } from '../../routes';
|
||||
import validateMessages from './validateMessages';
|
||||
import {
|
||||
emailNotVerified,
|
||||
signUpFailed,
|
||||
magicLinkSent,
|
||||
signUpSuccessful,
|
||||
} from './notifications';
|
||||
import {
|
||||
emailRules,
|
||||
passwordRules,
|
||||
requiredRules,
|
||||
} from '../../utils/form';
|
||||
|
||||
const { Item } = Form;
|
||||
|
||||
const SignUp = () => {
|
||||
const [formMagicLink] = Form.useForm();
|
||||
const [formEmail] = Form.useForm();
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isFacebookLoading, setIsFacebookLoading] = useState(false);
|
||||
const [isMagicLinkLoading, setIsMagicLinkLoading] = useState(false);
|
||||
const {
|
||||
currentUser,
|
||||
signUp,
|
||||
sendEmailVerification,
|
||||
googleAuth,
|
||||
githubAuth,
|
||||
facebookAuth,
|
||||
sendMagicLink,
|
||||
} = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const isAnythingLoading = isEmailLoading || isGoogleLoading || isGithubLoading || isFacebookLoading || isMagicLinkLoading;
|
||||
|
||||
const googleLogin = useCallback(async () => {
|
||||
setIsGoogleLoading(true);
|
||||
try {
|
||||
await googleAuth();
|
||||
} catch (error) {
|
||||
signUpFailed(error as Error);
|
||||
}
|
||||
}, [googleAuth]);
|
||||
|
||||
const githubLogin = useCallback(async () => {
|
||||
setIsGithubLoading(true);
|
||||
try {
|
||||
await githubAuth();
|
||||
} catch (error) {
|
||||
signUpFailed(error as Error);
|
||||
}
|
||||
}, [githubAuth]);
|
||||
|
||||
const facebookLogin = useCallback(async () => {
|
||||
setIsFacebookLoading(true);
|
||||
try {
|
||||
await facebookAuth();
|
||||
} catch (error) {
|
||||
signUpFailed(error as Error);
|
||||
}
|
||||
}, [facebookAuth]);
|
||||
|
||||
const emailSignUp = async ({ email, password, username }: { email: string, password: string, username: string }) => {
|
||||
setIsEmailLoading(true);
|
||||
try {
|
||||
const user = await signUp(email, password, username);
|
||||
await sendEmailVerification();
|
||||
signUpSuccessful();
|
||||
if (!user.emailVerification) {
|
||||
emailNotVerified();
|
||||
}
|
||||
navigate(Routes.HUB);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
signUpFailed(error as Error);
|
||||
formMagicLink.resetFields();
|
||||
formEmail.resetFields();
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const magicLinkLogin = async ({ email }: { email: string }) => {
|
||||
setIsMagicLinkLoading(true);
|
||||
try {
|
||||
await sendMagicLink(email);
|
||||
magicLinkSent();
|
||||
} catch (error) {
|
||||
signUpFailed(error as Error);
|
||||
} finally {
|
||||
setIsMagicLinkLoading(false);
|
||||
formMagicLink.resetFields();
|
||||
formEmail.resetFields();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
navigate(Routes.HUB);
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<Divider>Sign Up</Divider>
|
||||
<Space direction="horizontal" style={{ width: '100%', justifyContent: 'center' }}>
|
||||
<Button
|
||||
loading={isGoogleLoading}
|
||||
onClick={googleLogin}
|
||||
disabled={isAnythingLoading}
|
||||
>
|
||||
<GoogleOutlined />Google
|
||||
</Button>
|
||||
<Button
|
||||
loading={isGithubLoading}
|
||||
onClick={githubLogin}
|
||||
disabled={isAnythingLoading}
|
||||
>
|
||||
<GithubOutlined />GitHub
|
||||
</Button>
|
||||
<Button
|
||||
loading={isFacebookLoading}
|
||||
onClick={facebookLogin}
|
||||
disabled={isAnythingLoading}
|
||||
>
|
||||
<FacebookOutlined />Facebook
|
||||
</Button>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Form
|
||||
onFinish={magicLinkLogin}
|
||||
validateMessages={validateMessages}
|
||||
form={formMagicLink}
|
||||
>
|
||||
<Item name="email" rules={emailRules} hasFeedback>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="Email"
|
||||
id="email-magic-link"
|
||||
autoComplete="email"
|
||||
disabled={isAnythingLoading}
|
||||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
loading={isMagicLinkLoading}
|
||||
disabled={isAnythingLoading}
|
||||
icon={<MailOutlined />}
|
||||
>
|
||||
Send me a Magic Link
|
||||
</Button>
|
||||
</Item>
|
||||
</Form>
|
||||
<Form
|
||||
onFinish={emailSignUp}
|
||||
validateMessages={validateMessages}
|
||||
form={formEmail}
|
||||
>
|
||||
<Divider />
|
||||
<Item name="username" rules={requiredRules} hasFeedback>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="Username"
|
||||
autoComplete="name"
|
||||
disabled={isAnythingLoading}
|
||||
/>
|
||||
</Item>
|
||||
<Item name="email" rules={emailRules} hasFeedback>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="Email"
|
||||
autoComplete="email"
|
||||
disabled={isAnythingLoading}
|
||||
/>
|
||||
</Item>
|
||||
<Item
|
||||
name="password"
|
||||
rules={passwordRules}
|
||||
hasFeedback
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Password"
|
||||
autoComplete="new-password"
|
||||
prefix={<LockOutlined />}
|
||||
disabled={isAnythingLoading}
|
||||
/>
|
||||
</Item>
|
||||
<Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
style={{ width: '100%' }}
|
||||
loading={isEmailLoading}
|
||||
disabled={isAnythingLoading}
|
||||
icon={<UserAddOutlined />}
|
||||
>
|
||||
Sign Up using password
|
||||
</Button>
|
||||
</Item>
|
||||
<Link to={Routes.LOGIN} style={{ float: 'right' }}>
|
||||
Log In
|
||||
</Link>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUp;
|
|
@ -13,9 +13,9 @@ const emailNotVerified = () => notification.warning({
|
|||
...baseOptions,
|
||||
});
|
||||
|
||||
const magicLinkSent = () => notification.success({
|
||||
message: 'Check your email',
|
||||
description: 'Magic link sent!',
|
||||
const usernameNotSet = () => notification.warning({
|
||||
message: 'Update your profile',
|
||||
description: 'Your username has to be set before you can upload files!',
|
||||
...baseOptions,
|
||||
});
|
||||
|
||||
|
@ -33,7 +33,6 @@ const signUpFailed = (err: Error) => notification.error({
|
|||
|
||||
const logInSuccessful = () => notification.success({
|
||||
message: 'Log in successful',
|
||||
description: 'Welcome back!',
|
||||
...baseOptions,
|
||||
});
|
||||
|
||||
|
@ -51,13 +50,6 @@ const restrictedPage = () => notification.error({
|
|||
|
||||
const logOutSuccessful = () => notification.success({
|
||||
message: 'Log out successful',
|
||||
description: 'See you next time!',
|
||||
...baseOptions,
|
||||
});
|
||||
|
||||
const logOutFailed = (err: Error) => notification.error({
|
||||
message: 'Log out failed',
|
||||
description: err.message,
|
||||
...baseOptions,
|
||||
});
|
||||
|
||||
|
@ -73,12 +65,6 @@ const resetFailed = (err: Error) => notification.error({
|
|||
...baseOptions,
|
||||
});
|
||||
|
||||
const magicLinkConfirmationFailed = (err: Error) => notification.error({
|
||||
message: 'Magic Link is invalid',
|
||||
description: err.message,
|
||||
...baseOptions,
|
||||
});
|
||||
|
||||
const sendingEmailVerificationFailed = (err: Error) => notification.success({
|
||||
message: 'Sending verification email failed',
|
||||
description: err.message,
|
||||
|
@ -140,17 +126,15 @@ const copiedToClipboard = () => notification.success({
|
|||
|
||||
export {
|
||||
emailNotVerified,
|
||||
magicLinkSent,
|
||||
usernameNotSet,
|
||||
signUpSuccessful,
|
||||
signUpFailed,
|
||||
logInSuccessful,
|
||||
logInFailed,
|
||||
restrictedPage,
|
||||
logOutSuccessful,
|
||||
logOutFailed,
|
||||
resetSuccessful,
|
||||
resetFailed,
|
||||
magicLinkConfirmationFailed,
|
||||
sendingEmailVerificationFailed,
|
||||
emailVerificationSent,
|
||||
emailVerificationFailed,
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
import { fetchEnv } from './utils/env';
|
||||
|
||||
const API_URL = fetchEnv('VITE_POCKETBASE_API_URL');
|
||||
const client = new PocketBase(API_URL);
|
||||
|
||||
const formatError = (error: any) => {
|
||||
const { data, message } = error;
|
||||
|
||||
if (data.data) {
|
||||
const errors = Object.keys(data.data).map((key) => `${key.toUpperCase()}: ${data.data[key].message}`);
|
||||
if (errors.length > 0) {
|
||||
return errors.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export {
|
||||
API_URL,
|
||||
client,
|
||||
formatError,
|
||||
};
|
|
@ -19,11 +19,9 @@ export enum Routes {
|
|||
SIGN_UP = '/auth/sign-up',
|
||||
FORGOT_PASSWORD = '/auth/forgot-password',
|
||||
RESET_PASSWORD = '/auth/reset-password',
|
||||
MAGIC_LINK_CONFIRMATION = '/auth/magic-link-confirmation',
|
||||
EMAIL_VERIFICATION = '/auth/email-verification',
|
||||
RESET_PASSWORD_CONFIRMATION = '/auth/reset-password-confirmation',
|
||||
RESET_PASSWORD_CONFIRMATION = '/auth/reset-password-confirmation/:token',
|
||||
EMAIL_VERIFICATION = '/auth/email-verification/:token',
|
||||
OAUTH_CALLBACK = '/auth/oauth-callback/:provider',
|
||||
|
||||
REDIRECT_PAGE_MAGIC_LINK_CONFIRMATION = 'magic-link-confirmation',
|
||||
REDIRECT_PAGE_EMAIL_VERIFICATION = 'email-verification',
|
||||
REDIRECT_PAGE_RESET_PASSWORD = 'reset-password',
|
||||
REDIRECT_PAGE_OAUTH_CALLBACK = 'oauth',
|
||||
}
|
||||
|
|
|
@ -1,57 +1,15 @@
|
|||
import { Models } from 'appwrite';
|
||||
import { Record } from 'pocketbase';
|
||||
import {
|
||||
ProfilesRecord,
|
||||
TunesRecord,
|
||||
} from '../@types/pocketbase-types';
|
||||
|
||||
type Partial<T> = {
|
||||
[A in keyof T]?: T[A];
|
||||
};
|
||||
|
||||
export interface TuneDataDetails {
|
||||
readme?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
displacement?: number | null;
|
||||
year?: number | null;
|
||||
hp?: number | null;
|
||||
stockHp?: number | null;
|
||||
engineCode?: string | null;
|
||||
cylindersCount?: number | null;
|
||||
aspiration?: string | null;
|
||||
fuel?: string | null;
|
||||
injectorsSize?: number | null;
|
||||
coils?: string | null;
|
||||
}
|
||||
export type TunesRecordPartial = Partial<TunesRecord>;
|
||||
|
||||
export interface TuneDbData {
|
||||
userId: string;
|
||||
tuneId: string;
|
||||
signature: string;
|
||||
tuneFileId?: string | null;
|
||||
logFileIds?: string[];
|
||||
toothLogFileIds?: string[];
|
||||
customIniFileId?: string | null;
|
||||
vehicleName: string | null;
|
||||
engineMake: string | null;
|
||||
engineCode: string | null;
|
||||
displacement: number | null;
|
||||
cylindersCount: number | null;
|
||||
aspiration: 'na' | 'turbocharged' | 'supercharged';
|
||||
compression?: number | null;
|
||||
fuel?: string | null;
|
||||
ignition?: string | null;
|
||||
injectorsSize?: number | null;
|
||||
year?: number | null;
|
||||
hp?: number | null;
|
||||
stockHp?: number | null;
|
||||
readme: string | null;
|
||||
textSearch?: string | null;
|
||||
}
|
||||
export interface TunesRecordFull extends TunesRecord, Record { }
|
||||
|
||||
export interface TuneDbDocument extends TuneDbData, Models.Document {}
|
||||
|
||||
export type TuneDbDataPartial = Partial<TuneDbData>;
|
||||
|
||||
export interface UsersBucket {
|
||||
userId: string;
|
||||
bucketId: string;
|
||||
visibility: 'pubic' | 'private';
|
||||
createdAt: number;
|
||||
}
|
||||
export interface ProfilesRecordFull extends ProfilesRecord, Record { }
|
||||
|
|
|
@ -3,13 +3,13 @@ import {
|
|||
Logs,
|
||||
TuneWithDetails,
|
||||
} from '@hyper-tuner/types';
|
||||
import { TuneDbDocument } from './dbData';
|
||||
import { TunesRecordFull } from './dbData';
|
||||
|
||||
export interface ConfigState extends Config {}
|
||||
|
||||
export interface TuneState extends TuneWithDetails {}
|
||||
|
||||
export interface TuneDataState extends TuneDbDocument {}
|
||||
export interface TuneDataState extends TunesRecordFull {}
|
||||
|
||||
export interface LogsState extends Logs {}
|
||||
|
||||
|
|
|
@ -9,26 +9,24 @@ import {
|
|||
onProgress as onProgressType,
|
||||
} from './http';
|
||||
import TuneParser from './tune/TuneParser';
|
||||
import { TuneDbDocument } from '../types/dbData';
|
||||
import useServerStorage, { CDN_URL } from '../hooks/useServerStorage';
|
||||
import { TunesRecordFull } from '../types/dbData';
|
||||
|
||||
// TODO: refactor this!!
|
||||
export const loadTune = async (tuneData: TuneDbDocument | null, bucketId: string) => {
|
||||
export const loadTune = async (tuneData: TunesRecordFull | null) => {
|
||||
if (tuneData === null) {
|
||||
store.dispatch({ type: 'config/load', payload: null });
|
||||
store.dispatch({ type: 'tune/load', payload: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const pako = await import('pako');
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { getFileForDownload, getINIFile } = useServerStorage();
|
||||
const { getINIFile, fetchFileFromServer } = useServerStorage();
|
||||
|
||||
const started = new Date();
|
||||
const tuneRaw = await getFileForDownload(tuneData.tuneFileId!, bucketId);
|
||||
const tuneRaw = await fetchFileFromServer(tuneData.id, tuneData.tuneFile);
|
||||
|
||||
const tuneParser = new TuneParser()
|
||||
.parse(pako.inflate(new Uint8Array(tuneRaw)));
|
||||
const tuneParser = new TuneParser().parse(tuneRaw);
|
||||
|
||||
if (!tuneParser.isValid()) {
|
||||
console.error('Invalid tune');
|
||||
|
@ -38,9 +36,8 @@ export const loadTune = async (tuneData: TuneDbDocument | null, bucketId: string
|
|||
}
|
||||
|
||||
const tune = tuneParser.getTune();
|
||||
const iniRaw = tuneData.customIniFileId ? getFileForDownload(tuneData.customIniFileId, bucketId) : getINIFile(tuneData.signature);
|
||||
const buff = pako.inflate(new Uint8Array(await iniRaw));
|
||||
const config = new INI(buff).parse().getResults();
|
||||
const iniRaw = tuneData.customIniFile ? fetchFileFromServer(tuneData.id, tuneData.customIniFile) : getINIFile(tuneData.signature);
|
||||
const config = new INI(await iniRaw).parse().getResults();
|
||||
|
||||
// override / merge standard dialogs, constants and help
|
||||
config.dialogs = {
|
||||
|
|
|
@ -2,14 +2,20 @@ import { Rule } from 'antd/lib/form';
|
|||
|
||||
const REQUIRED_MESSAGE = 'This field is required';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
|
||||
const passwordPattern = /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})/;
|
||||
|
||||
const usernamePattern = /^[A-z][A-z0-9_\\-]{3,30}$/;
|
||||
|
||||
export const passwordRules: Rule[] = [
|
||||
{ required: true },
|
||||
{ pattern: passwordPattern, message: 'Password is too weak!' },
|
||||
];
|
||||
|
||||
export const usernameRules: Rule[] = [
|
||||
{ required: true },
|
||||
{ pattern: usernamePattern, message: 'Username has invalid format!' },
|
||||
];
|
||||
|
||||
export const emailRules: Rule[] = [{
|
||||
required: true,
|
||||
type: 'email',
|
||||
|
|
|
@ -6,3 +6,5 @@ export const camelToUrlCase = (str: string) => str
|
|||
|
||||
export const snakeToCamelCase = (str: string) => str
|
||||
.replace(/([-_]\w)/g, (g) => g[1].toUpperCase());
|
||||
|
||||
export const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
|
|
@ -2,5 +2,12 @@ import { fetchEnv } from './env';
|
|||
|
||||
export const buildFullUrl = (parts = [] as string[]) => `${fetchEnv('VITE_WEB_URL')}/#${parts.join('/')}`;
|
||||
|
||||
export const buildRedirectUrl = (page: string) => `${fetchEnv('VITE_WEB_URL')}?redirectPage=${page}`;
|
||||
export const buildRedirectUrl = (redirectPage: string, params: { [param: string]: string }) => {
|
||||
const url = new URL(fetchEnv('VITE_WEB_URL'));
|
||||
url.search = new URLSearchParams({
|
||||
redirect: redirectPage,
|
||||
...params,
|
||||
}).toString();
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
|
|
@ -17,7 +17,6 @@ export default defineConfig({
|
|||
sentry: ['@sentry/react', '@sentry/browser', '@sentry/tracing'],
|
||||
kbar: ['kbar'],
|
||||
perfectScrollbar: ['perfect-scrollbar'],
|
||||
appwrite: ['appwrite'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue