diff --git a/src/components/home/home.tsx b/src/components/home/home.tsx index bb6d6fa5..7a5d19fb 100644 --- a/src/components/home/home.tsx +++ b/src/components/home/home.tsx @@ -1,11 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import { useDispatch, useSelector } from "@root/store"; import { Header } from "@comp/header/header"; +import AddIcon from "@mui/icons-material/Add"; +import Button from "@mui/material/Button"; +import { addProject } from "@comp/profile/actions"; import Search from "./search"; import PopularProjects from "./popular-projects"; import RandomProjects from "./random-projects"; import PopularArtists from "./popular-artists"; import { homeBackground } from "./background-style"; +import { homeActionBar, homeCreateButton } from "./styles"; import { fetchPopularProjects } from "./actions"; import { selectPopularProjectsFetchOffset, @@ -68,6 +72,16 @@ const Home = () => { <>
+
+ +
{/*

Search is being fixed...

*/} {/* css` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid ${theme.line}; + background: linear-gradient( + 180deg, + ${theme.highlightBackgroundAlt}, + ${theme.highlightBackground} + ); + color: ${theme.textColor}; + font-weight: 600; + + &:hover { + background: ${theme.highlightBackgroundAlt}; + } +`; + export const homePageHeading = (theme: Theme): SerializedStyles => css` position: absolute; bottom: -6px; diff --git a/src/components/login/actions.tsx b/src/components/login/actions.tsx index 216fa7d2..94cae162 100644 --- a/src/components/login/actions.tsx +++ b/src/components/login/actions.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useState } from "react"; import { doc, getDoc, writeBatch } from "firebase/firestore"; -import { useDispatch } from "@root/store"; +import { AppThunk, useDispatch, useSelector } from "@root/store"; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import { @@ -13,7 +13,10 @@ import { CREATE_USER_FAIL, CREATE_USER_SUCCESS, CREATE_CLEAR_ERROR, - LOG_OUT + LOG_OUT, + LoginDialogMode, + PostAuthFlow, + SET_POST_AUTH_FLOW } from "./types"; import { closeModal, openSimpleModal } from "../modal/actions"; import { database, profiles, usernames } from "../../config/firestore"; @@ -33,12 +36,23 @@ import { SnackbarType } from "../snackbar/types"; import { IProfile } from "../profile/types"; import { navigateTo } from "@comp/router/navigate"; import { isElectron } from "@root/utils"; +import { selectPostAuthFlow } from "./selectors"; -export const login = ( - email: string, - password: string -): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +const openProjectCreationModal = () => + openSimpleModal("new-project-prompt", { + name: "New Project", + description: "", + label: "Create Project", + newProject: true, + projectID: "", + starterTemplate: "single-csd", + iconName: undefined, + iconForegroundColor: undefined, + iconBackgroundColor: undefined + }); + +export const login = (email: string, password: string): AppThunk => { + return async (dispatch) => { dispatch({ type: SIGNIN_REQUEST }); @@ -66,8 +80,8 @@ export const login = ( export const loginWithProvider = ( providerName: "google" | "facebook" -): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +): AppThunk => { + return async (dispatch) => { dispatch({ type: SIGNIN_REQUEST }); @@ -106,6 +120,7 @@ export function ProfileFinalize({ user: { displayName: string | undefined; uid: string }; }) { const dispatch = useDispatch(); + const postAuthFlow = useSelector(selectPostAuthFlow); const [input, setInput] = useState(""); const [displayName, setDisplayName] = useState(user.displayName || ""); const [nameReserved, setNameReserved] = useState(false); @@ -151,7 +166,6 @@ export function ProfileFinalize({ try { await batch.commit(); - dispatch(closeModal()); dispatch( openSnackbar("Profile created!", SnackbarType.Success) ); @@ -159,6 +173,13 @@ export function ProfileFinalize({ type: SIGNIN_SUCCESS, user }); + + if (postAuthFlow === "create-project") { + dispatch(setPostAuthFlow(undefined)); + dispatch(openProjectCreationModal()); + } else { + dispatch(closeModal()); + } } catch (error) { dispatch( openSnackbar( @@ -265,8 +286,8 @@ export function ProfileFinalize({ export const thirdPartyAuthSuccess = ( user: { uid: string; displayName: string | undefined }, fromAutoLogin: boolean -): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +): AppThunk => { + return async (dispatch, getState) => { let profile; try { @@ -299,11 +320,19 @@ export const thirdPartyAuthSuccess = ( ); } else { const profileData = profile.data(); + const postAuthFlow = selectPostAuthFlow(getState()); dispatch({ type: SIGNIN_SUCCESS, user }); + + if (!fromAutoLogin && postAuthFlow === "create-project") { + dispatch(setPostAuthFlow(undefined)); + dispatch(openProjectCreationModal()); + return; + } + !fromAutoLogin && profileData && navigateTo(`/profile/${profileData.username}`); @@ -311,24 +340,36 @@ export const thirdPartyAuthSuccess = ( }; }; -export const openLoginDialog = (): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +export const setPostAuthFlow = (postAuthFlow: PostAuthFlow): AppThunk => { + return async (dispatch) => { + dispatch({ + type: SET_POST_AUTH_FLOW, + postAuthFlow + }); + }; +}; + +export const openLoginDialog = ( + dialogMode: LoginDialogMode = "login" +): AppThunk => { + return async (dispatch) => { dispatch({ - type: OPEN_DIALOG + type: OPEN_DIALOG, + dialogMode }); }; }; -export const closeLoginDialog = (): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +export const closeLoginDialog = (): AppThunk => { + return async (dispatch) => { dispatch({ type: CLOSE_DIALOG }); }; }; -export const logOut = (): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +export const logOut = (): AppThunk => { + return async (dispatch) => { try { await getAuth().signOut(); } catch (error) { @@ -341,11 +382,8 @@ export const logOut = (): ((dispatch: any) => Promise) => { }; }; -export const createNewUser = ( - email: string, - password: string -): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +export const createNewUser = (email: string, password: string): AppThunk => { + return async (dispatch) => { try { const credentials = await createUserWithEmailAndPassword( getAuth(), @@ -366,18 +404,16 @@ export const createNewUser = ( }; }; -export const createUserClearError = (): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +export const createUserClearError = (): AppThunk => { + return async (dispatch) => { dispatch({ type: CREATE_CLEAR_ERROR }); }; }; -export const setRequestingStatus = ( - status: boolean -): ((dispatch: any) => Promise) => { - return async (dispatch: any) => { +export const setRequestingStatus = (status: boolean): AppThunk => { + return async (dispatch) => { dispatch({ type: SET_REQUESTING_STATUS, status diff --git a/src/components/login/login.tsx b/src/components/login/login.tsx index be590f4e..8dc1ab00 100644 --- a/src/components/login/login.tsx +++ b/src/components/login/login.tsx @@ -1,15 +1,12 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "@root/store"; import { Button, Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, TextField, LinearProgress, - Link + Link, + useMediaQuery } from "@mui/material"; import { login, @@ -23,13 +20,15 @@ import { selectLoginRequesting, selectLoginFail, selectErrorCode, - selectErrorMessage + selectErrorMessage, + selectLoginDialogMode } from "./selectors"; import { validateEmail, isElectron } from "@root/utils"; import * as SS from "./styles"; import { assoc, isEmpty, pipe } from "ramda"; +import { LoginDialogMode } from "./types"; -type LoginMode = "login" | "create" | "reset"; +type LoginMode = LoginDialogMode; interface ILoginLocalState { email: string; @@ -47,6 +46,8 @@ const Login = (): React.ReactElement => { const errorMessage = useSelector(selectErrorMessage); const fail = useSelector(selectLoginFail); const requesting = useSelector(selectLoginRequesting); + const dialogMode = useSelector(selectLoginDialogMode); + const isCompactDialog = useMediaQuery("(max-width:640px)"); const [localState, setLocalState] = useState({ email: "", newEmail: "", @@ -54,14 +55,26 @@ const Login = (): React.ReactElement => { password: "", newPassword: "", newPasswordConfirm: "", - loginMode: "login" + loginMode: dialogMode } as ILoginLocalState); + useEffect(() => { + setLocalState((previousState) => ({ + ...previousState, + loginMode: dialogMode + })); + }, [dialogMode]); + const switchLoginMode = (loginMode: LoginMode) => { dispatch(createUserClearError()); setLocalState(assoc("loginMode", loginMode, localState)); }; + const handleClose = () => { + dispatch(createUserClearError()); + dispatch(closeLoginDialog()); + }; + const errorBox = !isEmpty(errorMessage) && errorMessage && (
{"Error " + errorCode}
@@ -75,89 +88,16 @@ const Login = (): React.ReactElement => { localState.newPasswordConfirm !== localState.newPassword || !localState.newEmailValid; - const loginView = () => ( -
- Login - - - Please enter your email address and password - -
- { - setLocalState( - assoc("email", event.target.value, localState) - ); - }} - fullWidth - error={fail} - autoComplete="current-email" - /> - { - setLocalState( - assoc( - "password", - event.target.value, - localState - ) - ); - }} - fullWidth - error={fail} - autoComplete="current-password" - onKeyPress={(event) => - event.key === "Enter" && - dispatch( - login(localState.email, localState.password) - ) - } - /> - -
- -
- - - - -
+ const providerButtons = () => ( + <> +
or continue with
-
- switchLoginMode("reset")}> - Forgot password? - -
+ + ); + + const progressBar = ( +
+
); - const resetView = () => ( -
- Reset Password - - - Please enter your email address - -
- { - setLocalState( - pipe( - assoc("email", event.target.value), + const loginView = () => ( +
+
+ Account +

Sign in

+

+ Sign in to save projects and create new ones from home. +

+
+
+ { + event.preventDefault(); + dispatch(login(localState.email, localState.password)); + }} + > +
+ { + setLocalState( assoc( - "newEmailValid", - validateEmail(event.target.value) + "email", + event.target.value, + localState ) - )(localState) - ); - }} - fullWidth - error={fail} - autoComplete="current-email" - /> + ); + }} + fullWidth + error={fail} + autoComplete="current-email" + InputLabelProps={{ shrink: true }} + css={SS.authField} + /> + { + setLocalState( + assoc( + "password", + event.target.value, + localState + ) + ); + }} + fullWidth + error={fail} + autoComplete="current-password" + InputLabelProps={{ shrink: true }} + css={SS.authField} + /> +
+
+ switchLoginMode("reset")} + > + Forgot password? + +
+
+ + +
-
- -
- - -
+
+ ); + + const resetView = () => ( +
+
+ Account recovery +

Reset password

+

+ Enter your email to get a reset link. +

+
+
+
{ + event.preventDefault(); + if (localState.newEmailValid) { dispatch(resetPassword(localState.email)); switchLoginMode("login"); - }} - color="primary" - disabled={!localState.newEmailValid} - > - {"Reset"} - - - + } + }} + > +
+ { + setLocalState( + pipe( + assoc("email", event.target.value), + assoc( + "newEmailValid", + validateEmail(event.target.value) + ) + )(localState) + ); + }} + fullWidth + error={fail} + autoComplete="current-email" + InputLabelProps={{ shrink: true }} + css={SS.authField} + /> +
+
+ + +
+
+ {progressBar} +
); const signupView = () => ( -
- New Account - Please provide a valid email -
- { - setLocalState( - pipe( - assoc("newEmail", event.target.value), - assoc( - "newEmailValid", - validateEmail(event.target.value) +
+
+ New account +

Create your account

+

+ Create an account to save projects and start new ones from + home. +

+
+
+ { + event.preventDefault(); + if (!disabledBool) { + dispatch( + createNewUser( + localState.newEmail, + localState.newPassword ) - )(localState) - ); - }} - fullWidth - error={!localState.newEmailValid} - /> - - Choose a good password of minimum 6 characters length - - { - setLocalState( - assoc("newPassword", event.target.value, localState) - ); - }} - fullWidth - error={localState.newPassword.length < 5} - autoComplete="new-password" - /> - { - setLocalState( - assoc( - "newPasswordConfirm", - event.target.value, - localState - ) - ); + ); + } }} - fullWidth - error={ - localState.newPasswordConfirm.length < 5 || - localState.newPassword.length < 5 || - localState.newPasswordConfirm !== localState.newPassword - } - autoComplete="new-password" - /> - -
- -
- - - - +
+ { + setLocalState( + pipe( + assoc("newEmail", event.target.value), + assoc( + "newEmailValid", + validateEmail(event.target.value) + ) + )(localState) + ); + }} + fullWidth + error={!localState.newEmailValid} + InputLabelProps={{ shrink: true }} + css={SS.authField} + /> +

Use 6 or more characters.

+ { + setLocalState( + assoc( + "newPassword", + event.target.value, + localState + ) + ); + }} + fullWidth + error={localState.newPassword.length < 5} + autoComplete="new-password" + InputLabelProps={{ shrink: true }} + css={SS.authField} + /> + { + setLocalState( + assoc( + "newPasswordConfirm", + event.target.value, + localState + ) + ); + }} + fullWidth + error={ + localState.newPasswordConfirm.length < 5 || + localState.newPassword.length < 5 || + localState.newPasswordConfirm !== + localState.newPassword + } + autoComplete="new-password" + InputLabelProps={{ shrink: true }} + css={SS.authField} + /> +
+
+ + +
+ + {progressBar} + {providerButtons()} +
); @@ -360,11 +427,19 @@ const Login = (): React.ReactElement => { return ( { - dispatch(createUserClearError()); - dispatch(closeLoginDialog()); - }} + onClose={handleClose} open + fullWidth + maxWidth="xs" + fullScreen={isCompactDialog} + scroll="body" + PaperProps={{ + sx: { + m: { xs: 0, sm: 2 }, + borderRadius: { xs: 0, sm: 3 }, + overflow: "hidden" + } + }} > {renderView(localState.loginMode)} {errorBox} diff --git a/src/components/login/reducer.tsx b/src/components/login/reducer.tsx index b817db11..dec0531f 100644 --- a/src/components/login/reducer.tsx +++ b/src/components/login/reducer.tsx @@ -8,7 +8,10 @@ import { CREATE_USER_FAIL, CREATE_USER_SUCCESS, CREATE_CLEAR_ERROR, - LOG_OUT + LOG_OUT, + LoginDialogMode, + PostAuthFlow, + SET_POST_AUTH_FLOW } from "./types"; import { assoc, dissoc, pipe } from "ramda"; @@ -18,6 +21,8 @@ export interface ILoginReducer { errorMessage: string | undefined; failed: boolean; isLoginDialogOpen: boolean; + dialogMode: LoginDialogMode; + postAuthFlow: PostAuthFlow; loggedInUid: string | undefined; requesting: boolean; } @@ -28,6 +33,8 @@ const INITIAL_STATE: ILoginReducer = { errorMessage: undefined, failed: false, isLoginDialogOpen: false, + dialogMode: "login", + postAuthFlow: undefined, loggedInUid: undefined, // we start always with onAuthStateChanged requesting: true @@ -69,6 +76,7 @@ const LoginReducer = ( ...state, loggedInUid: action.user.uid, isLoginDialogOpen: false, + dialogMode: "login", requesting: false, authenticated: true }; @@ -78,6 +86,8 @@ const LoginReducer = ( return { ...restState, isLoginDialogOpen: false, + dialogMode: "login", + postAuthFlow: undefined, authenticated: false, requesting: false, failed: false, @@ -85,10 +95,25 @@ const LoginReducer = ( }; } case OPEN_DIALOG: { - return { ...state, isLoginDialogOpen: true }; + return { + ...state, + isLoginDialogOpen: true, + dialogMode: action.dialogMode || "login" + }; } case CLOSE_DIALOG: { - return { ...state, isLoginDialogOpen: false }; + return { + ...state, + isLoginDialogOpen: false, + dialogMode: "login", + postAuthFlow: undefined + }; + } + case SET_POST_AUTH_FLOW: { + return { + ...state, + postAuthFlow: action.postAuthFlow + }; } default: { return state; diff --git a/src/components/login/selectors.tsx b/src/components/login/selectors.tsx index 7946c219..06d04984 100644 --- a/src/components/login/selectors.tsx +++ b/src/components/login/selectors.tsx @@ -1,4 +1,5 @@ import { RootState } from "@root/store"; +import { LoginDialogMode, PostAuthFlow } from "./types"; export const selectLoginRequesting = ({ LoginReducer }: RootState): boolean => { return LoginReducer.requesting; @@ -29,3 +30,15 @@ export const selectLoggedInUid = ({ }: RootState): string | undefined => { return LoginReducer.loggedInUid; }; + +export const selectLoginDialogMode = ({ + LoginReducer +}: RootState): LoginDialogMode => { + return LoginReducer.dialogMode; +}; + +export const selectPostAuthFlow = ({ + LoginReducer +}: RootState): PostAuthFlow => { + return LoginReducer.postAuthFlow; +}; diff --git a/src/components/login/styles.ts b/src/components/login/styles.ts deleted file mode 100644 index c7918ba5..00000000 --- a/src/components/login/styles.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { css, SerializedStyles, Theme } from "@emotion/react"; - -export const errorBox = (theme: Theme): SerializedStyles => css` - color: ${theme.errorText}; -`; - -export const centerLink = css` - text-align: center; - margin-bottom: 10px; -`; - -export const providerButtonsContainer = css` - display: grid; - gap: 8px; - padding: 0 24px 16px; -`; diff --git a/src/components/login/styles.tsx b/src/components/login/styles.tsx new file mode 100644 index 00000000..adf6b886 --- /dev/null +++ b/src/components/login/styles.tsx @@ -0,0 +1,233 @@ +import { css, SerializedStyles, Theme } from "@emotion/react"; + +export const dialogShell = css` + display: flex; + flex-direction: column; + min-width: min(420px, calc(100vw - 32px)); + background: #2f3645; + color: #eef2ff; + + @media (max-width: 640px) { + min-width: auto; + width: 100%; + min-height: 100%; + } +`; + +export const dialogHeader = (theme: Theme): SerializedStyles => css` + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px 24px 12px; + border-bottom: 1px solid ${theme.line}; + background: linear-gradient( + 180deg, + ${theme.highlightBackgroundAlt}, + ${theme.highlightBackground} + ); + + @media (max-width: 640px) { + padding: 22px 18px 12px; + } +`; + +export const dialogEyebrow = css` + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgb(233 239 255 / 72%); +`; + +export const dialogTitle = css` + margin: 0; + font-size: 28px; + line-height: 1.1; + color: #f8faff; + + @media (max-width: 640px) { + font-size: 24px; + } +`; + +export const dialogSubtitle = css` + margin: 0; + font-size: 14px; + line-height: 1.5; + color: rgb(231 236 252 / 86%); +`; + +export const dialogBody = css` + display: flex; + flex-direction: column; + gap: 18px; + padding: 18px 24px 24px; + color: #edf1ff; + + @media (max-width: 640px) { + padding: 16px 18px 18px; + gap: 16px; + } +`; + +export const fieldStack = css` + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const actionStack = css` + display: flex; + flex-direction: column; + gap: 10px; +`; + +export const providerDivider = (theme: Theme): SerializedStyles => css` + display: flex; + align-items: center; + gap: 10px; + color: rgb(224 230 247 / 78%); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; + + &::before, + &::after { + content: ""; + flex: 1; + height: 1px; + background: ${theme.line}; + } +`; + +export const progressContainer = css` + min-height: 4px; +`; + +export const providerButtonsContainer = css` + display: grid; + gap: 8px; +`; + +export const centerLink = css` + display: flex; + justify-content: center; +`; + +export const helperCopy = css` + margin: 0; + font-size: 13px; + line-height: 1.5; + color: rgb(224 230 247 / 82%); +`; + +export const authField = css` + .MuiOutlinedInput-root { + background: rgb(42 49 64 / 92%); + color: #f8faff; + font-size: 16px; + } + + .MuiOutlinedInput-root fieldset { + border-color: rgb(173 186 214 / 32%); + } + + .MuiOutlinedInput-root:hover fieldset { + border-color: rgb(173 186 214 / 52%); + } + + .MuiOutlinedInput-root.Mui-focused fieldset { + border-color: #87b7ff; + border-width: 1px; + } + + .MuiInputBase-input { + color: #f8faff; + -webkit-text-fill-color: #f8faff; + caret-color: #f8faff; + } + + .MuiOutlinedInput-root input:-webkit-autofill, + .MuiOutlinedInput-root input:-webkit-autofill:hover, + .MuiOutlinedInput-root input:-webkit-autofill:focus, + .MuiOutlinedInput-root textarea:-webkit-autofill, + .MuiOutlinedInput-root textarea:-webkit-autofill:hover, + .MuiOutlinedInput-root textarea:-webkit-autofill:focus { + -webkit-text-fill-color: #f8faff; + caret-color: #f8faff; + box-shadow: 0 0 0 1000px rgb(42 49 64 / 92%) inset; + transition: background-color 9999s ease-in-out 0s; + border-radius: inherit; + } + + .MuiInputLabel-root { + color: rgb(224 230 247 / 80%); + } + + .MuiInputLabel-root.Mui-focused { + color: #dce7ff; + } + + .MuiInputLabel-root.MuiInputLabel-shrink { + color: rgb(224 230 247 / 72%); + } + + .MuiFormHelperText-root { + color: rgb(224 230 247 / 74%); + } +`; + +export const subtleLink = css` + color: #cfe0ff !important; + cursor: pointer; + text-decoration-color: rgb(207 224 255 / 70%); + + &:hover { + color: #f8faff !important; + text-decoration-color: #f8faff; + } +`; + +export const secondaryAction = css` + background: rgb(83 94 120 / 95%) !important; + color: #f5f7ff !important; + + &:hover { + background: rgb(97 109 138 / 95%) !important; + } +`; + +export const providerButton = css` + border-color: rgb(173 186 214 / 55%) !important; + color: #f5f7ff !important; + background: rgb(51 59 76 / 45%); + + &:hover { + border-color: rgb(207 224 255 / 82%) !important; + background: rgb(67 76 97 / 72%); + } +`; + +export const errorBox = (theme: Theme): SerializedStyles => css` + margin: 0 24px 24px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid ${theme.errorText}; + background: rgb(150 0 0 / 10%); + color: ${theme.errorText}; + + h5 { + margin: 0 0 4px; + font-size: 13px; + } + + p { + margin: 0; + font-size: 12px; + line-height: 1.45; + } + + @media (max-width: 640px) { + margin: 0 18px 18px; + } +`; diff --git a/src/components/login/types.ts b/src/components/login/types.ts index 65c86e70..392509a0 100644 --- a/src/components/login/types.ts +++ b/src/components/login/types.ts @@ -10,3 +10,7 @@ export const LOG_OUT = PREFIX + "LOG_OUT"; export const CREATE_USER_FAIL = PREFIX + "CREATE_USER_FAIL"; export const CREATE_USER_SUCCESS = PREFIX + "CREATE_USER_SUCCESS"; export const CREATE_CLEAR_ERROR = PREFIX + "CREATE_USER_CLEAR_ERROR"; +export const SET_POST_AUTH_FLOW = PREFIX + "SET_POST_AUTH_FLOW"; + +export type LoginDialogMode = "login" | "create" | "reset"; +export type PostAuthFlow = "create-project" | undefined; diff --git a/src/components/modal/index.tsx b/src/components/modal/index.tsx index f6115feb..327e2ac5 100644 --- a/src/components/modal/index.tsx +++ b/src/components/modal/index.tsx @@ -25,7 +25,7 @@ function getModalStyle(width: number, height: number) { if (!width || !height) { return {}; } - const viewportPadding = 2; + const viewportPadding = window.innerWidth <= 760 ? 16 : 12; const topOffset = Math.max( viewportPadding, window.innerHeight / 2 - height / 2 @@ -110,7 +110,7 @@ export default function GlobalModal() { ? always : modalProperties.onClose || onClose } - style={{ zIndex: 3 }} + style={{ zIndex: 1000 }} >
= [ + { + value: "single-csd", + label: "Single CSD", + description: "One commented Csound 7 project.csd file." + }, + { + value: "split-csd", + label: "CSD + ORC + SCO", + description: "Split the new starter into project.csd, .orc, and .sco." + }, + { + value: "empty", + label: "Empty", + description: "Start with a blank project.csd file." + } +]; + +const getStarterFiles = ( + starterTemplate: ProjectStarterTemplate, + loggedInUserUid: string +) => { + const withUser = (document: { + name: string; + value: string; + type: string; + }) => ({ + ...document, + userUid: loggedInUserUid + }); + + switch (starterTemplate) { + case "split-csd": + return [ + withUser(defaultSplitCsd), + withUser(defaultOrc), + withUser(defaultSco) + ]; + case "empty": + return [withUser(emptyCsd)]; + case "single-csd": + default: + return [withUser(defaultCsd)]; + } +}; const addUserProjectAction = (): ProfileActionTypes => { return { @@ -134,7 +194,8 @@ export const addUserProject = projectUid: string, iconName: string, iconForegroundColor: string, - iconBackgroundColor: string + iconBackgroundColor: string, + starterTemplate: ProjectStarterTemplate ) => async (dispatch: AppThunkDispatch, getState: () => RootState) => { const currentState = getState(); @@ -156,31 +217,36 @@ export const addUserProject = const newProjectReference = doc(projects); batch.set(newProjectReference, newProject); const filesReference = collection(newProjectReference, "files"); - const csdFileReference = doc(filesReference); - batch.set(csdFileReference, { - ...defaultCsd, - userUid: loggedInUserUid - }); - batch.set(doc(filesReference), { - ...defaultOrc, - userUid: loggedInUserUid - }); - batch.set(doc(filesReference), { - ...defaultSco, - userUid: loggedInUserUid + const starterFiles = getStarterFiles( + starterTemplate, + loggedInUserUid + ); + let csdFileReferenceId: string | undefined; + + starterFiles.forEach((starterFile) => { + const fileReference = doc(filesReference); + + if (starterFile.name === "project.csd") { + csdFileReferenceId = fileReference.id; + } + + batch.set(fileReference, starterFile); }); + batch.set( doc(targets, newProjectReference.id), { - targets: { - "project.csd": { - csoundOptions: {}, - targetName: "project.csd", - targetType: "main", - targetDocumentUid: csdFileReference.id - } - }, - defaultTarget: "project.csd" + targets: csdFileReferenceId + ? { + "project.csd": { + csoundOptions: {}, + targetName: "project.csd", + targetType: "main", + targetDocumentUid: csdFileReferenceId + } + } + : {}, + defaultTarget: csdFileReferenceId ? "project.csd" : "" }, { merge: true } ); @@ -191,7 +257,10 @@ export const addUserProject = currentTags ); dispatch(addUserProjectAction()); + dispatch(closeModal()); + navigateTo(`/editor/${newProjectReference.id}`); dispatch(openSnackbar("Project Added", SnackbarType.Success)); + return newProjectReference.id; } catch (error) { console.log(error); dispatch( @@ -306,19 +375,34 @@ export const setTagsInput = (tags: Array): ProfileActionTypes => { // } // }; -export const addProject = () => { +const openNewProjectModal = () => { return openSimpleModal("new-project-prompt", { name: "New Project", description: "", label: "Create Project", newProject: true, projectID: "", + starterTemplate: "single-csd", iconName: undefined, iconForegroundColor: undefined, iconBackgroundColor: undefined }); }; +export const addProject = () => { + return async (dispatch: AppThunkDispatch, getState: () => RootState) => { + const loggedInUserUid = selectLoggedInUid(getState()); + + if (loggedInUserUid) { + dispatch(openNewProjectModal()); + return; + } + + dispatch(setPostAuthFlow("create-project")); + dispatch(openLoginDialog("create")); + }; +}; + export const followUser = (loggedInUserUid: string, profileUid: string) => async () => { const batch = writeBatch(database); @@ -444,6 +528,7 @@ export const editProject = (project: IProject) => { description: project.description, label: "Apply changes", projectID: project.projectUid, + starterTemplate: "single-csd", iconName: project.iconName, iconForegroundColor: project.iconForegroundColor, iconBackgroundColor: project.iconBackgroundColor, diff --git a/src/components/profile/profile-lists.tsx b/src/components/profile/profile-lists.tsx index 64c6080c..b9614614 100644 --- a/src/components/profile/profile-lists.tsx +++ b/src/components/profile/profile-lists.tsx @@ -1,6 +1,8 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Link } from "react-router"; import { useDispatch, useSelector } from "@root/store"; +import { shallowEqual } from "react-redux"; +import { createSelector } from "@reduxjs/toolkit"; import { List, ListItem, ListItemText } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import LockIcon from "@mui/icons-material/Lock"; @@ -31,11 +33,14 @@ import { StyledListButtonsContainer } from "./profile-ui"; import { IProject } from "@comp/projects/types"; +import { IProfile } from "./types"; import { editProject, deleteProject } from "./actions"; import { markProjectPublic } from "@comp/projects/actions"; import { descend, sort, propOr } from "ramda"; import * as SS from "./styles"; +const EMPTY_STRING_ARRAY: string[] = []; + const ProjectListItem = ({ isProfileOwner, project @@ -271,19 +276,37 @@ export const ProfileLists = ({ profileUid: string; selectedSection: number; isProfileOwner: boolean; - filteredProjects: Array; + filteredProjects: IProject[]; }) => { - const userFollowing = useSelector( - (state) => - state.ProfileReducer.profiles[profileUid]?.userFollowing ?? [] + const userFollowingSelector = useMemo( + () => + createSelector( + [(state) => state.ProfileReducer.profiles], + (profiles): string[] => + profiles[profileUid]?.following ?? EMPTY_STRING_ARRAY + ), + [profileUid] ); - const userFollowers = useSelector((state) => - (state.ProfileReducer.profiles[profileUid]?.followers ?? []).map( - (followerUid) => state.ProfileReducer.profiles[followerUid] - ) + const userFollowersSelector = useMemo( + () => + createSelector( + [(state) => state.ProfileReducer.profiles], + (profiles): Array => { + const followerUids = + profiles[profileUid]?.followers ?? EMPTY_STRING_ARRAY; + + return followerUids.map( + (followerUid: string) => profiles[followerUid] + ); + } + ), + [profileUid] ); + const userFollowing = useSelector(userFollowingSelector, shallowEqual); + const userFollowers = useSelector(userFollowersSelector, shallowEqual); + // Loading states const followingLoading = useSelector(selectFollowingLoading(profileUid)); const followersLoading = useSelector(selectFollowersLoading(profileUid)); diff --git a/src/components/profile/profile-modal.tsx b/src/components/profile/profile-modal.tsx index 0840abcb..4f84bd54 100644 --- a/src/components/profile/profile-modal.tsx +++ b/src/components/profile/profile-modal.tsx @@ -13,8 +13,13 @@ import Select from "react-select"; const ModalContainer = styled.div` display: grid; grid-auto-rows: minmax(60px, auto); - grid-template-columns: 400px; + grid-template-columns: minmax(0, 400px); + width: min(400px, calc(100vw - 32px)); border-radius: 5px; + + @media (max-width: 760px) { + width: min(400px, calc(100vw - 40px)); + } `; interface IFieldRow { diff --git a/src/components/profile/profile-ui.tsx b/src/components/profile/profile-ui.tsx index 7a7711d8..462d2713 100644 --- a/src/components/profile/profile-ui.tsx +++ b/src/components/profile/profile-ui.tsx @@ -1,42 +1,59 @@ import Card from "@mui/material/Card"; import Chip from "@mui/material/Chip"; -import { isMobile } from "@root/utils"; import styled from "@emotion/styled"; import { css } from "@emotion/react"; +const MOBILE_BP = "(max-width: 760px)"; +const DESKTOP_BP = "(min-width: 761px)"; + export const ProfileContainer = styled.div` - ${isMobile() - ? `padding: 16px; - box-sizing: border-box;` - : `display: grid; - grid-template-columns: 24px 250px 800px; - grid-template-rows: 50px 175px 1fr 70px; - grid-auto-rows: minmax(90px, auto);`} width: 100%; overflow-x: hidden; + box-sizing: border-box; + @media ${MOBILE_BP} { + padding: 16px; + } + @media ${DESKTOP_BP} { + display: grid; + grid-template-columns: 250px 1fr; + grid-template-rows: 120px auto; + grid-auto-rows: auto; + align-items: start; + padding: 24px; + } `; export const IDContainer = styled(Card)` - grid-row-start: 2; - grid-row-end: 3; - grid-column-start: 2; - grid-column-end: 3; + grid-row: 1 / -1; + grid-column: 1 / 2; display: grid; - grid-template-rows: 250px 1fr auto 60px; + grid-template-rows: 250px 1fr auto auto; grid-template-columns: 1fr; z-index: 2; min-height: 420px; - box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.8); + box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.6); + border: 1px solid rgba(255, 255, 255, 0.08); & > div { max-width: 250px; } - overflow: initial; - height: fit-content; + overflow: hidden; + align-self: stretch; + position: sticky; + top: 70px; + max-height: calc(100vh - 80px); +`; + +export const MobileAboutSection = styled.div` + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 16px; `; export const DescriptionSection = styled.div` grid-row: ${(properties: { gridRow: string }) => properties.gridRow}; grid-column: 1; - padding: 20px; + padding: 16px; + overflow-y: auto; div, a, h1, @@ -47,17 +64,21 @@ export const DescriptionSection = styled.div` a > div { font-weight: 300; font-size: 14px; - line-height: 32px; + line-height: 1.5; white-space: nowrap; text-decoration: underline; } - height: fit-content; `; export const EditProfileButtonSection = styled.div` grid-row: 4; grid-column: 1; - margin: auto; + display: flex; + flex-direction: column; + padding: 12px 16px 16px; + gap: 8px; + width: 100%; + box-sizing: border-box; `; export const ProfilePictureContainer = styled.div` @@ -114,31 +135,42 @@ export const ProfilePicture = styled.img` object-fit: cover; `; export const NameSectionWrapper = styled.div` - grid-row: 2; - grid-column: 3; + grid-row: 1; + grid-column: 2; + align-self: end; display: grid; grid-template-rows: 1fr auto; grid-template-columns: 1fr; - min-width: ${isMobile() ? "0" : "680px"}; + @media ${DESKTOP_BP} { + padding-left: 32px; + } `; export const NameSection = styled.div` grid-row: 2; grid-column: 1; color: white; - padding: 20px; + @media ${MOBILE_BP} { + padding: 12px 0 4px; + } + @media ${DESKTOP_BP} { + padding: 16px 24px; + } `; export const ContentSection = styled.div` - grid-row-start: 3; - grid-row-end: auto; - grid-column: 3; + grid-row: 2; + grid-column: 2; + align-self: start; z-index: 2; - ${!isMobile() && "margin-left: 48px;"} + margin-top: 16px; background: ${(properties) => properties.theme.background}; border-radius: 4px; box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.8); - width: 100%; - max-width: 100%; + min-width: 0; overflow-x: hidden; + min-height: 300px; + @media ${DESKTOP_BP} { + margin-left: 32px; + } `; export const ContentTabsContainer = styled.div` background-color: rgba(0, 0, 0, 0.2); @@ -148,15 +180,16 @@ export const contentActionsStyle = css` display: flex; justify-content: space-between; align-items: center; - height: 58px; + height: 56px; padding-left: 24px; - padding-right: 12px; + padding-right: 24px; margin-top: 12px; margin-bottom: 24px; `; export const ListContainer = styled.div` - padding-top: 10px; + padding-top: 8px; + padding-bottom: 16px; grid-row: 3; grid-column: 1; width: 100%; @@ -217,8 +250,8 @@ export const StyledListItemTopRowText = styled.div` text-align: left; & p { white-space: pre-line; - padding-right: 6px; - padding-top: 6px; + padding-right: 8px; + padding-top: 8px; } `; export const StyledListItemChipsRow = styled.div` @@ -251,7 +284,7 @@ export const StyledListButtonsContainer = styled.div` & button { width: 100%; align-self: center; - margin-top: 6px; + margin-top: 8px; } `; diff --git a/src/components/profile/profile.tsx b/src/components/profile/profile.tsx index cd03c3d6..e50f7dc5 100644 --- a/src/components/profile/profile.tsx +++ b/src/components/profile/profile.tsx @@ -1,12 +1,13 @@ import { doc, getDoc } from "firebase/firestore"; import { useDispatch, useSelector } from "@root/store"; import { ProfileLists } from "./profile-lists"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTheme } from "@emotion/react"; -import { isMobile, updateBodyScroller } from "@root/utils"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { updateBodyScroller } from "@root/utils"; import { gradient } from "./gradient"; import { usernames } from "@config/firestore"; -import { useLocation, useParams, useNavigate } from "react-router"; +import { useParams, useNavigate } from "react-router"; import { createButtonAddIcon } from "./styles"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; @@ -18,8 +19,10 @@ import CameraIcon from "@mui/icons-material/CameraAltOutlined"; import AddIcon from "@mui/icons-material/Add"; import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined"; import PersonAddAlt1OutlinedIcon from "@mui/icons-material/PersonAddAlt1Outlined"; +import PersonOutlineIcon from "@mui/icons-material/PersonOutline"; import PeopleOutlineIcon from "@mui/icons-material/PeopleOutline"; import StarBorderOutlinedIcon from "@mui/icons-material/StarBorderOutlined"; +import EditIcon from "@mui/icons-material/Edit"; import Box from "@mui/material/Box"; import SearchIcon from "@mui/icons-material/Search"; import TextField from "@mui/material/TextField"; @@ -74,9 +77,37 @@ import { fabButton, mobileNavigationContainer, mobileNavigationButton, - profileMobileBottomSpacer + profileMobileBottomSpacer, + MobileAboutSection } from "./profile-ui"; +type ProfileSection = + | "projects" + | "following" + | "followers" + | "stars" + | "about"; + +const PROFILE_ROUTE_SECTION_MAP: Record< + string, + Exclude +> = { + following: "following", + followers: "followers", + stars: "stars" +}; + +const PROFILE_SECTION_VALUE: Record = { + projects: 0, + following: 1, + followers: 2, + stars: 3, + about: 4 +}; + +const getRouteSection = (tab?: string): Exclude => + PROFILE_ROUTE_SECTION_MAP[tab || ""] || "projects"; + const UserLink = ({ link }: { link: string | undefined }) => { return typeof link === "string" ? ( @@ -90,23 +121,43 @@ const UserLink = ({ link }: { link: string | undefined }) => { }; export const Profile = () => { - const [profileUid, setProfileUid]: [string | undefined, any] = useState(); + const [profileUid, setProfileUid] = useState(); const theme = useTheme(); const dispatch = useDispatch(); const navigate = useNavigate(); const { username, tab } = useParams(); - const profile = useSelector(selectUserProfile(profileUid)); - const imageUrl = useSelector(selectUserImageURL(profileUid)); const loggedInUserUid = useSelector(selectLoggedInUid); - const filteredProjects = useSelector(selectUserProjects(profileUid)); + const profileSelector = useMemo( + () => selectUserProfile(profileUid), + [profileUid] + ); + const imageUrlSelector = useMemo( + () => selectUserImageURL(profileUid), + [profileUid] + ); + const projectsSelector = useMemo( + () => selectUserProjects(profileUid), + [profileUid] + ); + const followingSelector = useMemo( + () => selectUserFollowing(loggedInUserUid), + [loggedInUserUid] + ); + + const profile = useSelector(profileSelector); + const imageUrl = useSelector(imageUrlSelector); + const filteredProjects = useSelector(projectsSelector); // const followingFilterString = useSelector(selectFollowingFilterString); const projectFilterString = useSelector(selectProjectFilterString); const [imageHover, setImageHover] = useState(false); - const [selectedSection, setSelectedSection] = useState(0); - const loggedInUserFollowing: string[] = useSelector( - selectUserFollowing(loggedInUserUid) - ); + const [isAboutSelected, setIsAboutSelected] = useState(false); + const loggedInUserFollowing: string[] = useSelector(followingSelector); + const isMobileLayout = useMediaQuery("(max-width: 760px)"); + const routeSection = getRouteSection(tab); + + const selectedSection: ProfileSection = + isMobileLayout && isAboutSelected ? "about" : routeSection; const isFollowing = profileUid ? loggedInUserFollowing.includes(profileUid) @@ -123,18 +174,10 @@ export const Profile = () => { }, []); useEffect(() => { - if (username) { - if (!tab && selectedSection !== 0) { - setSelectedSection(0); - } else if (tab === "following" && selectedSection !== 1) { - setSelectedSection(1); - } else if (tab === "followers" && selectedSection !== 2) { - setSelectedSection(2); - } else if (tab === "stars" && selectedSection !== 3) { - setSelectedSection(3); - } + if (!isMobileLayout || routeSection !== getRouteSection(tab)) { + setIsAboutSelected(false); } - }, [tab, selectedSection, username]); + }, [isMobileLayout, routeSection, tab]); useEffect(() => { if (!isRequestingLogin) { @@ -172,7 +215,7 @@ export const Profile = () => { ), subscribeToProfileStars(profileUid, dispatch), subscribeToProjectsCount(profileUid, dispatch) - ] as any[]; + ]; // make sure the logged in user's following is listed // when viewing another profile, for un/follow state if (loggedInUserUid && !isProfileOwner) { @@ -221,12 +264,153 @@ export const Profile = () => { } }, [displayName]); + const selectedSectionValue = PROFILE_SECTION_VALUE[selectedSection]; + const isProjectsSection = selectedSection === "projects"; + const isAboutSection = selectedSection === "about"; + + const handleSectionChange = (section: ProfileSection) => { + if (section === "about") { + setIsAboutSelected(true); + return; + } + setIsAboutSelected(false); + switch (section) { + case "projects": { + navigate(`/profile/${username}`); + return; + } + case "following": { + navigate(`/profile/${username}/following`); + return; + } + case "followers": { + navigate(`/profile/${username}/followers`); + return; + } + case "stars": { + navigate(`/profile/${username}/stars`); + return; + } + } + }; + + const profileActions = ( + <> + {isProfileOwner && profileUid && ( + + + + )} + {!isProfileOwner && profileUid && loggedInUserUid && ( + + + + )} + + ); + + const desktopProfileDetails = ( + <> + + + Bio + + + {profile && profile.bio} + + + + + Links + + {profile && ( + <> + + + + + )} + + {profileActions} + + ); + + const mobileAboutContent = ( + +
+ + Bio + + + {profile?.bio || "No bio available."} + +
+
+ + Links + + {profile ? ( + <> + + + + + ) : null} +
+ {profileActions} +
+ ); + return (
- {!isMobile() && ( + {!isMobileLayout && ( setImageHover(true)} @@ -280,93 +464,15 @@ export const Profile = () => { )} - - - Bio - - - {profile && profile.bio} - - - - - Links - - {profile && ( - <> - - - - - )} - - {isProfileOwner && profileUid && ( - - - - )} - {!isProfileOwner && - profileUid && - loggedInUserUid && ( - - - - )} + {desktopProfileDetails} )} - + {profile && displayName} @@ -374,40 +480,23 @@ export const Profile = () => { - {!isMobile() && ( + {!isMobileLayout && ( { - switch (index) { - case 0: { - navigate( - `/profile/${username}` - ); - break; - } - case 1: { - navigate( - `/profile/${username}/following` - ); - break; - } - case 2: { - navigate( - `/profile/${username}/followers` - ); - break; - } - case 3: { - navigate( - `/profile/${username}/stars` - ); - break; - } - } - }} + value={selectedSectionValue} + onChange={(_, value: number) => + handleSectionChange( + value === 1 + ? "following" + : value === 2 + ? "followers" + : value === 3 + ? "stars" + : "projects" + ) + } indicatorColor={"primary"} > @@ -418,84 +507,96 @@ export const Profile = () => { )} - - {selectedSection === 0 && ( - - - - ) - }} - onChange={(event) => { - dispatch( - setProjectFilterString( - event.target.value + {!isAboutSection && ( + + {isProjectsSection && ( + + + ) - ); - }} - /> - )} + }} + onChange={(event) => { + dispatch( + setProjectFilterString( + event.target.value + ) + ); + }} + /> + )} - {isProfileOwner && selectedSection === 0 && ( - - )} - - {profileUid && username && ( + {isProfileOwner && isProjectsSection && ( + + )} + + )} + {profileUid && username && !isAboutSection && ( )} + {isMobileLayout && isAboutSection && mobileAboutContent} - {isMobile() &&
} + {isMobileLayout &&
} - {isMobile() && ( + {isMobileLayout && ( + handleSectionChange( + value === 1 + ? "following" + : value === 2 + ? "followers" + : value === 3 + ? "stars" + : value === 4 + ? "about" + : "projects" + ) + } css={mobileNavigationContainer} showLabels > navigate(`/profile/${username}`)} css={mobileNavigationButton} label="Projects" icon={} /> - navigate(`/profile/${username}/following`) - } css={mobileNavigationButton} label="Following" icon={ @@ -504,22 +605,22 @@ export const Profile = () => { /> - navigate(`/profile/${username}/followers`) - } css={mobileNavigationButton} label="Followers" icon={} /> - navigate(`/profile/${username}/stars`) - } css={mobileNavigationButton} label="Stars" icon={} /> + } + /> )} diff --git a/src/components/profile/project-modal.tsx b/src/components/profile/project-modal.tsx index 404e49f2..2c4f9fdb 100644 --- a/src/components/profile/project-modal.tsx +++ b/src/components/profile/project-modal.tsx @@ -1,9 +1,8 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { useDispatch } from "@root/store"; import Tooltip from "@mui/material/Tooltip"; import SVGPaths from "@elem/svg-icons"; import ProjectAvatar from "@elem/project-avatar"; -import { IProject } from "@comp/projects/types"; import { SliderPicker } from "react-color"; import Radio from "@mui/material/Radio"; import RadioGroup from "@mui/material/RadioGroup"; @@ -14,97 +13,260 @@ import styled from "@emotion/styled"; import IconButton from "@mui/material/IconButton"; import ReactAutosuggestExample from "./tag-auto-suggest"; import { isEmpty } from "ramda"; -import { addUserProject, editUserProject } from "./actions"; +import { + addUserProject, + editUserProject, + PROJECT_STARTER_TEMPLATE_OPTIONS, + ProjectStarterTemplate +} from "./actions"; import { openSnackbar } from "../snackbar/actions"; import { SnackbarType } from "../snackbar/types"; import { closeModal } from "../modal/actions"; const avatarContainer = css` - margin-left: -10px; - margin-top: -10px; - width: 62px; - height: 62px; - padding: 14px; - -webkit-box-pack: center; + width: 88px; + height: 88px; + padding: 16px; + border-radius: 18px; + display: flex; + align-items: center; justify-content: center; cursor: pointer; + background: rgb(0 0 0 / 10%); + border: 1px solid rgb(255 255 255 / 10%); + .project-avatar { position: relative; border-radius: 50%; + transform: scale(1.15); } `; const ModalContainer = styled.div` - display: grid; - grid-auto-rows: minmax(72px, auto); - grid-template-columns: min(400px, calc(100vw - 24px)); - border-radius: 5px; - box-sizing: border-box; - padding: 6px 2px; - row-gap: 6px; - - h2 { - margin: 0; - line-height: 1.3; - } + width: min(520px, calc(100vw - 24px)); + display: flex; + flex-direction: column; + gap: 16px; @media (max-width: 760px) { - grid-template-columns: calc(100vw - 24px); - grid-auto-rows: minmax(58px, auto); - row-gap: 8px; - padding: 8px 2px; - border-radius: 8px; + width: calc(100vw - 24px); + gap: 14px; } `; -interface IFieldRow { - row: number; -} -const FieldRow = styled.div` - grid-row: ${(properties) => properties.row}; - grid-column: 1; + +const HeaderBlock = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const HeaderEyebrow = styled.span` + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.68; +`; + +const HeaderTitle = styled.h2` + margin: 0; + line-height: 1.15; + font-size: 28px; @media (max-width: 760px) { - padding-left: 2px; - padding-right: 2px; + font-size: 24px; } `; -const IconPickerContainer = styled.div` +const HeaderBody = styled.p` + margin: 0; + line-height: 1.5; + opacity: 0.82; + font-size: 14px; +`; + +const StepRail = styled.div` display: grid; - position: relative; - grid-template-columns: 1fr 1fr 1fr; - grid-template-rows: 1fr; - grid-gap: 10px; - padding: 4px; - align-items: center; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +`; - @media (max-width: 760px) { - grid-template-columns: 1fr; - grid-template-rows: auto auto auto; - grid-gap: 12px; - justify-items: stretch; +const StepCard = styled.button<{ active: boolean; complete: boolean }>` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid + ${(properties) => + properties.active + ? "rgb(255 255 255 / 24%)" + : properties.complete + ? "rgb(255 255 255 / 12%)" + : "rgb(255 255 255 / 8%)"}; + background: ${(properties) => + properties.active ? "rgb(255 255 255 / 8%)" : "rgb(0 0 0 / 7%)"}; + color: inherit; + text-align: left; + cursor: pointer; + + &:disabled { + cursor: default; + opacity: 1; } `; -const StyledSketchPicker = styled(SliderPicker)` - grid-column: 2; - grid-row: 1; +const StepIndex = styled.span` + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.66; +`; - @media (max-width: 760px) { - grid-column: 1; - grid-row: 2; - width: 100% !important; - max-width: 100%; +const StepLabel = styled.span` + font-size: 13px; + font-weight: 700; + line-height: 1.3; +`; + +const Section = styled.section` + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border-radius: 14px; + background: rgb(0 0 0 / 7%); + border: 1px solid rgb(255 255 255 / 8%); +`; + +const SectionTitle = styled.h3` + margin: 0; + font-size: 15px; +`; + +const SectionCaption = styled.p` + margin: 0; + line-height: 1.45; + font-size: 13px; + opacity: 0.76; +`; + +const FieldStack = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const fieldSx = { + "& .MuiFormHelperText-root": { + color: "rgb(224 230 247 / 78%)", + marginLeft: 0, + marginRight: 0 + }, + "& .MuiInputLabel-root": { + color: "rgb(224 230 247 / 84%)" + }, + "& .MuiInputLabel-root.Mui-focused": { + color: "rgb(248 250 255 / 92%)" + } +}; + +const StarterTemplateContainer = styled.div` + display: grid; + gap: 8px; + + .MuiFormControlLabel-root { + align-items: flex-start; + margin: 0; + padding: 10px 12px; + border-radius: 12px; + background: rgb(0 0 0 / 7%); + border: 1px solid transparent; + transition: + border-color 120ms ease, + background-color 120ms ease; + } + + .MuiFormControlLabel-root.selected { + border-color: rgb(255 255 255 / 24%); + background: rgb(255 255 255 / 6%); + } + + .MuiFormControlLabel-label { + display: grid; + gap: 3px; + } +`; + +const TemplateDescription = styled.span` + opacity: 0.76; + font-size: 12px; + line-height: 1.4; +`; + +const TemplateGuide = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-radius: 12px; + background: rgb(255 255 255 / 4%); + border: 1px solid rgb(255 255 255 / 6%); +`; + +const TemplateGuideTitle = styled.span` + font-size: 13px; + font-weight: 700; +`; + +const TemplateGuideLine = styled.span` + font-size: 12px; + line-height: 1.45; + opacity: 0.76; +`; + +const IconPickerContainer = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: 12px; + + @media (min-width: 760px) { + grid-template-columns: 110px minmax(0, 1fr) 130px; + align-items: center; } `; +const AvatarPreviewCard = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + text-align: center; +`; + +const AvatarPreviewHint = styled.span` + font-size: 12px; + opacity: 0.76; + line-height: 1.4; +`; + +const StyledSketchPicker = styled(SliderPicker)` + width: 100% !important; + max-width: 100%; +`; + const RadioGroupContainer = styled.div` - grid-column: 3; - grid-row: 1; + display: flex; + align-items: center; - @media (max-width: 760px) { - grid-column: 1; - grid-row: 3; + .MuiRadioGroup-root { + width: 100%; + } + + .MuiFormControlLabel-root { + margin-left: 0; + margin-right: 0; } `; @@ -120,6 +282,29 @@ const PopoverContainer = styled.div` } `; +const FooterActions = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + + @media (min-width: 760px) { + flex-direction: row; + justify-content: space-between; + } +`; + +const FooterLeadingActions = styled.div` + display: flex; + gap: 10px; + flex-direction: column; + + @media (min-width: 760px) { + flex-direction: row; + } +`; + +type WizardStep = 0 | 1 | 2; + interface IProjectModal { name: string; description: string; @@ -129,9 +314,41 @@ interface IProjectModal { iconBackgroundColor: string | undefined; iconName: string | undefined; newProject: boolean; + starterTemplate?: ProjectStarterTemplate; } +const templateGuides: Record< + ProjectStarterTemplate, + { title: string; lines: string[] } +> = { + "single-csd": { + title: "Best for quick starts", + lines: [ + "One self-contained file.", + "Good for teaching, sketching, and fast edits.", + "Opens with a ready-to-play Csound 7 example." + ] + }, + "split-csd": { + title: "Best for structured projects", + lines: [ + "Keeps wrapper, orchestra, and score separate.", + "Still starts from the same new default example.", + "Useful when you want students to compare roles of each file." + ] + }, + empty: { + title: "Best for blank starts", + lines: [ + "Creates an empty project.csd.", + "No starter notes or comments.", + "Useful when you already know the structure you want." + ] + } +}; + export const ProjectModal = (properties: IProjectModal) => { + const dispatch = useDispatch(); const [name, setName] = useState(properties.name); const [description, setDescription] = useState(properties.description); const [iconName, setIconName] = useState(properties.iconName); @@ -142,47 +359,61 @@ export const ProjectModal = (properties: IProjectModal) => { const [iconBackgroundColor, setIconBackgroundColor] = useState( properties.iconBackgroundColor || "#000" ); - + const [starterTemplate, setStarterTemplate] = + useState( + properties.starterTemplate || "single-csd" + ); const [popupState, setPopupState] = useState(false); const [anchorElement, setAnchorElement] = useState( null as HTMLSpanElement | null ); - const dispatch = useDispatch(); - // const currentTags = useSelector(selectTags(properties.projectID)); - const [modifiedTags, setModifiedTags] = useState([]); - const shouldDisable = isEmpty(name); + const [modifiedTags, setModifiedTags] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [step, setStep] = useState(0); - // useEffect(() => { - // if (not(equals(currentTags, modifiedTags)) && !isEmpty(currentTags)) { - // setModifiedTags(currentTags); - // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [currentTags]); + const shouldDisable = isEmpty(name.trim()) || isSubmitting; + const isWizard = properties.newProject; + + const activeTemplateGuide = useMemo( + () => templateGuides[starterTemplate], + [starterTemplate] + ); const handleOnSubmit = async () => { + if (shouldDisable) { + return; + } + + setIsSubmitting(true); + try { - dispatch( - properties.newProject - ? addUserProject( - name, - description, - modifiedTags, - properties.projectID, - iconName || "default", - iconForegroundColor || "#000000", - iconBackgroundColor || "#FFF" - ) - : editUserProject( - name, - description, - modifiedTags, - properties.projectID, - iconName || "default", - iconForegroundColor || "#000000", - iconBackgroundColor || "#FFF" - ) - ); - dispatch(closeModal()); + if (properties.newProject) { + await dispatch( + addUserProject( + name.trim(), + description, + modifiedTags, + properties.projectID, + iconName || "default", + iconForegroundColor || "#000000", + iconBackgroundColor || "#FFF", + starterTemplate + ) as any + ); + } else { + await dispatch( + editUserProject( + name.trim(), + description, + modifiedTags, + properties.projectID, + iconName || "default", + iconForegroundColor || "#000000", + iconBackgroundColor || "#FFF" + ) as any + ); + dispatch(closeModal()); + } } catch (error) { dispatch( openSnackbar( @@ -190,9 +421,10 @@ export const ProjectModal = (properties: IProjectModal) => { SnackbarType.Error ) ); + } finally { + setIsSubmitting(false); } }; - const textFieldStyle = { marginBottom: 12 }; const handleProfileDropDown = ( event: React.MouseEvent @@ -206,167 +438,320 @@ export const ProjectModal = (properties: IProjectModal) => { setPopupState(false); }; + const stepMeta = [ + { label: "Project details" }, + { label: "Starter template" }, + { label: "Visual identity" } + ] as const; + + const goToStep = (nextStep: WizardStep) => { + if (!isWizard) { + return; + } + setStep(nextStep); + }; + return ( - - {properties.newProject ? ( -

Please Name Your Project

- ) : ( -

{`Editing "${name}"`}

- )} -
- - - { - setName(event.target.value); - }} - fullWidth - /> - - - { - setDescription(event.target.value); - }} - margin="normal" - fullWidth - /> - - - - - - - - + + {properties.newProject ? "New Project" : "Project Settings"} + + + {properties.newProject + ? "Create a project" + : `Edit ${name || "project"}`} + + + {properties.newProject + ? "Set up the project in three short steps, then jump into the editor." + : "Update the name, description, tags, and icon for this project."} + + + + {isWizard && ( + + {stepMeta.map((item, index) => ( + index} + onClick={() => goToStep(index as WizardStep)} > - - - - - - - {Array.isArray(Object.entries(SVGPaths)) && - Object.entries(SVGPaths).map( - (entry, index) => { - const SvgElem: any = - entry[1] as any; - - return ( - - { - setIconName( - entry[0] - ); - handlePopoverClose(); - }} - > - - - - ); - } - )} - - - - { - if (foregroundColor) { - setIconForegroundColor(event.hex); - } else { - setIconBackgroundColor(event.hex); + {`Step ${index + 1}`} + {item.label} + + ))} + + )} + + {(!isWizard || step === 0) && ( +
+ Project details + Start with the basics. + + - + value={name} + onChange={(event) => { + setName(event.target.value); + }} + fullWidth + sx={fieldSx} + /> + { + setDescription(event.target.value); + }} + fullWidth + sx={fieldSx} + /> + + +
+ )} + + {properties.newProject && step === 1 && ( +
+ Starter template + + Choose the file structure before opening the editor. + + { - setIsForegroundColor( - event.target.value === "foreground" - ? true - : false + setStarterTemplate( + event.target.value as ProjectStarterTemplate ); }} > - } - label="Foreground" - /> - } - label="Background" - /> + {PROJECT_STARTER_TEMPLATE_OPTIONS.map((option) => ( + } + label={ + <> + {option.label} + + {option.description} + + + } + /> + ))} - - - - - - + + + + {activeTemplateGuide.title} + + {activeTemplateGuide.lines.map((line) => ( + + {line} + + ))} + +
+ )} + + {(!isWizard || step === 2) && ( +
+ Visual identity + + Choose an icon and colors that are easy to recognize. + + + + + + + + + + Tap the avatar to browse icons. + + + + + + + {Array.isArray(Object.entries(SVGPaths)) && + Object.entries(SVGPaths).map( + (entry, index) => { + const SvgElem: any = + entry[1] as any; + + return ( + + { + setIconName( + entry[0] + ); + handlePopoverClose(); + }} + > + + + + ); + } + )} + + + + + { + if (foregroundColor) { + setIconForegroundColor(event.hex); + } else { + setIconBackgroundColor(event.hex); + } + }} + /> + + + { + setIsForegroundColor( + event.target.value === "foreground" + ); + }} + > + } + label="Edit foreground" + /> + } + label="Edit background" + /> + + + +
+ )} + + + + + {isWizard && step > 0 && ( + + )} + + {isWizard && step < 2 ? ( + + ) : ( + + )} +
); }; diff --git a/src/components/profile/selectors.ts b/src/components/profile/selectors.ts index ffe0bf7f..13c361ab 100644 --- a/src/components/profile/selectors.ts +++ b/src/components/profile/selectors.ts @@ -4,33 +4,40 @@ import { IProfileReducer } from "./reducer"; import { path, pathOr } from "ramda"; // import Fuse from "fuse.js"; import { IProject } from "../projects/types"; +import { IProfile } from "./types"; -export const selectUserFollowing = - (profileUid: string | undefined): ((store: RootState) => Array) => - (store: RootState) => { - if (profileUid) { - const state: IProfileReducer = store.ProfileReducer; - return pathOr([], ["profiles", profileUid, "following"], state); - } else { - return []; - } - }; +const EMPTY_STRING_ARRAY: string[] = []; +const EMPTY_PROJECT_ARRAY: IProject[] = []; +const EMPTY_PROJECTS_COUNT = { + all: 0, + public: 0 +}; -export const selectUserProjects = - (profileUid: string | undefined) => (store: RootState) => { - if (profileUid) { - const state = store.ProjectsReducer.projects; +export const selectUserFollowing = (profileUid: string | undefined) => + createSelector( + [(store: RootState) => store.ProfileReducer.profiles], + (profiles) => { + if (!profileUid) { + return EMPTY_STRING_ARRAY; + } + + return profiles[profileUid]?.following ?? EMPTY_STRING_ARRAY; + } + ); + +export const selectUserProjects = (profileUid: string | undefined) => + createSelector( + [(store: RootState) => store.ProjectsReducer.projects], + (projects) => { + if (!profileUid) { + return EMPTY_PROJECT_ARRAY; + } - // Filter projects by matching `userUid` with `profileUid` - const filteredProjects = Object.values(state).filter( + return Object.values(projects).filter( (project) => project.userUid === profileUid ); - - return filteredProjects; - } else { - return []; } - }; + ); export const selectProjectFilterString = ( store: RootState @@ -47,7 +54,8 @@ export const selectFollowingFilterString = ( }; export const selectUserProfile = - (profileUid: string | undefined) => (store: RootState) => { + (profileUid: string | undefined) => + (store: RootState): IProfile | undefined => { if (profileUid) { const state: IProfileReducer = store.ProfileReducer; return state.profiles[profileUid]; @@ -76,9 +84,13 @@ export const selectLoggedInUserStars = (store: RootState): Array => { const loggedInUid: string | undefined = store.LoginReducer.loggedInUid; if (loggedInUid) { const state: IProfileReducer = store.ProfileReducer; - return pathOr([], ["profiles", loggedInUid, "starred"], state); + return pathOr( + EMPTY_STRING_ARRAY, + ["profiles", loggedInUid, "starred"], + state + ); } else { - return []; + return EMPTY_STRING_ARRAY; } }; @@ -135,15 +147,15 @@ export const selectLoggedInUserStars = (store: RootState): Array => { // )(store); export const selectFilteredUserFollowers = - (profileUid: string): ((store: RootState) => any) => + (profileUid: string): ((store: RootState) => Array) => (store) => { const state: IProfileReducer = store.ProfileReducer; const followerUids = pathOr( - [], + EMPTY_STRING_ARRAY, ["profiles", profileUid, "followers"], state ); - return (followerUids || []).map((followerUid) => + return followerUids.map((followerUid) => path(["profiles", followerUid], state) ); }; @@ -171,23 +183,20 @@ export const selectCurrentTagText = (store: RootState): string => { }; export const selectTags = - (projectUid: string): ((store: RootState) => any) => + (projectUid: string): ((store: RootState) => string[]) => (store) => { return pathOr( - [], + EMPTY_STRING_ARRAY, ["ProjectsReducer", "projects", projectUid, "tags"], store ); }; export const selectProfileStars = - (profileUid: string) => (store: RootState) => { - return pathOr( - [], - ["ProfileReducer", "profiles", profileUid, "stars"], - store - ); - }; + (profileUid: string) => + (store: RootState): string[] => + store.ProfileReducer?.profiles?.[profileUid]?.stars ?? + EMPTY_STRING_ARRAY; export const selectAllUserProjectUids = (profileUid: string | undefined) => (store: RootState) => { @@ -201,42 +210,31 @@ export const selectAllUserProjectUids = return allUserProjects.map((p) => p.projectUid); } else { - return []; + return EMPTY_STRING_ARRAY; } }; export const selectAllTagsFromUser = - (profileUid: string): ((store: RootState) => any) => + (profileUid: string): ((store: RootState) => string[]) => (store) => { return pathOr( - [], + EMPTY_STRING_ARRAY, ["ProfileReducer", "profiles", profileUid, "allTags"], store ); }; -// Selector to get profileUid (customize this as per your state structure) -export const selectProfileUid = createSelector( - [(state: RootState) => state.LoginReducer.loggedInUid], // Replace with the correct state slice - (loggedInUid) => loggedInUid -); +export const selectProfileUid = (state: RootState): string | undefined => + state.LoginReducer.loggedInUid; // Selector to get the projectsCount for the logged-in user export const selectProfileProjectsCount = createSelector( [selectProfiles, selectProfileUid], (profiles, profileUid) => { if (!profileUid || !profiles[profileUid]) { - return { - all: 0, - public: 0 - }; + return EMPTY_PROJECTS_COUNT; } - return ( - profiles[profileUid].projectsCount ?? { - all: 0, - public: 0 - } - ); + return profiles[profileUid].projectsCount ?? EMPTY_PROJECTS_COUNT; } ); @@ -244,24 +242,13 @@ export const selectProfileProjectsCount = createSelector( export const selectUserProjectsCount = (profileUid: string | undefined) => (store: RootState) => { if (!profileUid) { - return { - all: 0, - public: 0 - }; + return EMPTY_PROJECTS_COUNT; } const profiles = selectProfiles(store); if (!profiles[profileUid]) { - return { - all: 0, - public: 0 - }; + return EMPTY_PROJECTS_COUNT; } - return ( - profiles[profileUid].projectsCount ?? { - all: 0, - public: 0 - } - ); + return profiles[profileUid].projectsCount ?? EMPTY_PROJECTS_COUNT; }; // export const selectProjectIconStyle = ( diff --git a/src/components/profile/tabs/stars-list.tsx b/src/components/profile/tabs/stars-list.tsx index 6e9f81a5..040e01c3 100644 --- a/src/components/profile/tabs/stars-list.tsx +++ b/src/components/profile/tabs/stars-list.tsx @@ -13,12 +13,14 @@ import { Box, CircularProgress } from "@mui/material"; -import { useSelector } from "react-redux"; +import { useSelector, shallowEqual } from "react-redux"; import { useNavigate } from "react-router"; import { isEmpty } from "ramda"; import StarIcon from "@mui/icons-material/Star"; import * as SS from "./styles"; +const EMPTY_STRING_ARRAY: string[] = []; + export const StarsList = ({ profileUid, isLoading = false @@ -27,7 +29,12 @@ export const StarsList = ({ isLoading?: boolean; }) => { const navigate = useNavigate(); - const profileStars = useSelector(selectProfileStars(profileUid)); + const profileStars = useSelector( + (store: RootState): string[] => + store.ProfileReducer?.profiles?.[profileUid]?.stars ?? + EMPTY_STRING_ARRAY, + shallowEqual + ); const cachedProjects = useSelector( (store: RootState) => store.ProjectsReducer.projects ); diff --git a/src/components/profile/types.ts b/src/components/profile/types.ts index 2b175fe6..185c5207 100644 --- a/src/components/profile/types.ts +++ b/src/components/profile/types.ts @@ -59,11 +59,11 @@ export type ProfileActionTypes = { }; export interface IProfile { - allTags?: any[]; + allTags?: string[]; bio?: string; userUid: string; - userFollowing?: []; - followers?: []; + following?: string[]; + followers?: string[]; projectsCount?: ProjectsCount; userImageURL?: string; backgroundIndex: number; @@ -75,4 +75,5 @@ export interface IProfile { username: string; photoUrl?: string; userJoinDate?: number; + stars?: string[]; } diff --git a/src/csound-templates/default-split.csd b/src/csound-templates/default-split.csd new file mode 100644 index 00000000..fb9722af --- /dev/null +++ b/src/csound-templates/default-split.csd @@ -0,0 +1,11 @@ + + +-o dac ; real-time audio output + + +#include "project.orc" + + +#include "project.sco" + + \ No newline at end of file diff --git a/src/csound-templates/default.csd b/src/csound-templates/default.csd index 06b45e78..86a802ef 100644 --- a/src/csound-templates/default.csd +++ b/src/csound-templates/default.csd @@ -1,20 +1,27 @@ +-o dac ; real-time audio output -0dbfs=1 -nchnls=2 +sr = 44100 ; sample rate +0dbfs = 1 ; full-scale amplitude +nchnls = 2 ; stereo output +ksmps = 64 ; control rate block size +; Instrument 1 plays a simple sawtooth tone. +; p4 is amplitude and p5 is frequency. instr 1 - asig vco2 p4, p5 - aenv linenr asig,0.01,0.1,0.01 - out aenv, aenv + iAmp = p4 + iFreq = p5 + aOut = vco2:a(iAmp, iFreq) + outall(aOut) endin -event_i "i", 1, 0, 2, 0dbfs/2, A4 -event_i "e", 0, 2.5 - +; A short "hello world" phrase for new projects. +i 1 0 0.35 0.10 440 +i 1 0.45 0.35 0.08 660 +i 1 0.95 0.60 0.06 550 \ No newline at end of file diff --git a/src/csound-templates/default.orc b/src/csound-templates/default.orc index ab81e7ef..ca2a2f4e 100644 --- a/src/csound-templates/default.orc +++ b/src/csound-templates/default.orc @@ -1,16 +1,13 @@ -sr=44100 -ksmps=32 -0dbfs=1 -nchnls=2 +sr = 44100 ; sample rate +0dbfs = 1 ; full-scale amplitude +nchnls = 2 ; stereo output +ksmps = 64 ; control rate block size +; Instrument 1 plays a simple sawtooth tone. +; p4 is amplitude and p5 is frequency. instr 1 - - iamp = ampdbfs(p5) - ipch = cps2pch(p4,12) - ipan = 0.5 - - asig = vco2(iamp, ipch) - al, ar pan2 asig, ipan - - out(al, ar) + iAmp = p4 + iFreq = p5 + aOut = vco2:a(iAmp, iFreq) + outall(aOut) endin \ No newline at end of file diff --git a/src/csound-templates/default.sco b/src/csound-templates/default.sco index a869164e..48b24c40 100644 --- a/src/csound-templates/default.sco +++ b/src/csound-templates/default.sco @@ -1 +1,4 @@ -i1 0 2 8.00 -12 \ No newline at end of file +; A short "hello world" phrase for new projects. +i 1 0 0.35 0.10 440 +i 1 0.45 0.35 0.08 660 +i 1 0.95 0.60 0.06 550 \ No newline at end of file diff --git a/src/csound-templates/index.js b/src/csound-templates/index.js index 238e0f34..0d308495 100644 --- a/src/csound-templates/index.js +++ b/src/csound-templates/index.js @@ -1,4 +1,5 @@ import * as defaultCsdTxt from "./default.csd?raw"; +import * as defaultSplitCsdTxt from "./default-split.csd?raw"; import * as defaultOrcTxt from "./default.orc?raw"; import * as defaultScoTxt from "./default.sco?raw"; @@ -8,6 +9,12 @@ export const defaultCsd = { type: "txt" }; +export const defaultSplitCsd = { + name: "project.csd", + value: defaultSplitCsdTxt.default, + type: "txt" +}; + export const defaultOrc = { name: "project.orc", value: defaultOrcTxt.default, @@ -19,3 +26,9 @@ export const defaultSco = { value: defaultScoTxt.default, type: "txt" }; + +export const emptyCsd = { + name: "project.csd", + value: "", + type: "txt" +};