24 Commits

Author SHA1 Message Date
Mikayla Dobson
2754fe6c09 progress: work on inner section of recipe create function 2023-02-25 21:13:43 -06:00
Mikayla Dobson
8a939e6a81 in progress: handle add ingredients to recipe 2023-02-25 20:21:29 -06:00
Mikayla Dobson
63d0049450 in progress: associate ingredients to recipe on save 2023-02-19 12:04:46 -06:00
Mikayla Dobson
47360518ce fraction support on addRecipe, working on state management 2023-02-19 11:16:26 -06:00
Mikayla Dobson
949762f3a0 reworking view recipe by id 2023-02-18 13:48:11 -06:00
Mikayla Dobson
6d4ebd7757 course dropdown 2023-02-18 12:39:02 -06:00
Mikayla Dobson
e7a27d7fe9 add course dropdown values 2023-02-18 12:21:50 -06:00
Mikayla Dobson
e89067d942 styling work 2023-02-18 11:57:31 -06:00
Mikayla Dobson
1d4763333b oops nevermind LOL 2023-02-18 11:05:48 -06:00
Mikayla Dobson
a7f3fd6e10 api maintenance 2023-02-18 10:58:58 -06:00
Mikayla Dobson
9e146f0825 fetches and displays measurement units 2023-02-16 17:57:40 -06:00
Mikayla Dobson
8ae6cf4ab0 in progress: table for units of measurements, etc 2023-02-16 17:25:10 -06:00
Mikayla Dobson
46454a84c2 extensible inputs on recipe add page 2023-02-16 16:50:18 -06:00
Mikayla Dobson
8f1cfa0ad9 preparing to change ingredient entry strategy 2023-02-16 14:49:19 -06:00
Mikayla Dobson
1e85a714dc working on ingredient selector 2023-02-16 14:12:07 -06:00
Mikayla Dobson
99829533fd workshopping multi select 2023-02-16 10:35:37 -06:00
Mikayla Dobson
1daf3418ce bringing in dropdown library 2023-02-15 12:09:04 -06:00
Mikayla Dobson
a30960a1b4 bit of work on the form component 2023-02-14 19:45:32 -06:00
Mikayla Dobson
28c4747aba more customization on user/versus other-user profiles 2023-02-14 14:19:19 -06:00
Mikayla Dobson
9945ebadb4 add recipe workflow, viewing for collections 2023-02-13 21:19:29 -06:00
Mikayla Dobson
fc1046bad5 bit of work on friends 2023-02-13 19:25:28 -06:00
Mikayla Dobson
7bd29e4dba working on profile 2023-02-13 18:13:01 -06:00
Mikayla Dobson
bd282ce2bb frontend api refactoring, req.user handling on backend 2023-02-13 17:13:37 -06:00
Mikayla Dobson
1b32ac38d1 Merge pull request #1 from innocuous-symmetry/api-with-jwt
Api with jwt
2023-02-13 15:18:39 -06:00
75 changed files with 2430 additions and 445 deletions

View File

@@ -9,12 +9,16 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@mui/material": "^5.11.9",
"@tinymce/tinymce-react": "^4.2.0",
"axios": "^1.2.0",
"jwt-decode": "^3.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.3",
"react-select": "^5.7.0",
"sass": "^1.56.1",
"uuid": "^9.0.0"
},

View File

@@ -2,8 +2,6 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useAuthContext } from './context/AuthContext';
import jwtDecode from 'jwt-decode';
import API from './util/API';
// pages, ui, components, styles
import Subscriptions from './components/pages/Subscriptions/Subscriptions';
@@ -19,9 +17,10 @@ import CollectionBrowser from './components/pages/CollectionBrowser';
import { Navbar } from './components/ui';
import GroceryList from './components/pages/GroceryList';
import GroceryListCollection from './components/pages/GroceryListCollection';
import { TokenType } from './util/types';
import './sass/App.scss';
import handleToken from './util/handleToken';
import AddFriends from './components/pages/AddFriends';
import Sandbox from './components/Sandbox';
import './sass/App.scss';
function App() {
const { setUser, token, setToken } = useAuthContext();
@@ -48,20 +47,26 @@ function App() {
<div className="App">
<Navbar />
<Routes>
{/* Base access privileges */}
<Route path="/" element={<Welcome />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
{/* Protected routes */}
<Route path="/profile" element={<Profile />} />
<Route path="/collections" element={<CollectionBrowser />} />
<Route path="/collections/:id" element={<Collection />} />
<Route path="/add-friends" element={<AddFriends />} />
<Route path="/explore" element={<Browser header="" searchFunction={() => {}} />} />
<Route path="/recipe/:id" element={<Recipe />} />
<Route path="/subscriptions" element={<Subscriptions />} />
<Route path="/subscriptions/:id" element={<Collection />} />
<Route path="/add-recipe" element={<AddRecipe />} />
<Route path="/grocery-list" element={<GroceryListCollection />} />
<Route path="/grocery-list/:id" element={<GroceryList />} />
{/* For dev use */}
<Route path="/sandbox" element={<Sandbox />} />
</Routes>
</div>
</BrowserRouter>

View File

@@ -0,0 +1,93 @@
import Select, { MultiValue } from "react-select";
import Creatable, { useCreatable } from "react-select/creatable";
import { useEffect, useState } from "react"
import { useAuthContext } from "../context/AuthContext";
import { ICollection, IIngredient } from "../schemas";
import { Button, Page } from "./ui";
import API from "../util/API";
import { OptionType } from "../util/types";
export default function Sandbox() {
const [ingredients, setIngredients] = useState<Array<IIngredient>>([]);
const [collections, setCollections] = useState<Array<OptionType>>([]);
const [newEntries, setNewEntries] = useState<Array<OptionType>>([]);
const [selections, setSelections] = useState<Array<OptionType>>([]);
const { user, token } = useAuthContext();
function handleNewIngredient(name: string) {
if (!user || !user.id) return;
let maxID = 0;
ingredients.forEach((entry: IIngredient) => {
if (entry.id! > maxID) {
maxID = entry.id!;
}
});
const newEntry: OptionType = {
label: name,
value: maxID + 1
}
const newIngredient: IIngredient = {
id: maxID + 1,
name: name,
createdbyid: user.id
}
setIngredients(prev => [...prev, newIngredient]);
setNewEntries(prev => [...prev, newEntry]);
}
function handleChange(event: MultiValue<OptionType>) {
setSelections(prev => [...prev, ...event])
}
async function bulkInsertIngredients() {
if (!user || !token) return;
console.log(newEntries.length - ingredients.length);
}
useEffect(() => {
token && (async() => {
const ingredients = new API.Ingredient(token);
const collections = new API.Collection(token);
const allIngredients = await ingredients.getAll();
if (allIngredients) {
setNewEntries(prev => [...prev, ...allIngredients]);
setIngredients(allIngredients.map((each: IIngredient) => {
return { label: each.name, value: each.id }
}));
}
const myCollections = await collections.getAllAuthored();
if (myCollections) setCollections(myCollections.map((each: ICollection) => {
return { label: each.name, value: each.id }
}))
})();
}, [token])
useEffect(() => {
console.log(newEntries);
}, [newEntries])
return (
<Page>
<h1>Sandbox</h1>
<p>Ingredients:</p>
{ ingredients
? <Creatable isMulti value={selections} onChange={(value: MultiValue<OptionType>) => handleChange(value)} onCreateOption={handleNewIngredient} options={newEntries} />
: <p>Loading...</p>
}
<p>My collections:</p>
{ collections ? <Select options={collections} /> : <p>Loading...</p> }
<Button onClick={bulkInsertIngredients}>Go</Button>
</Page>
)
}

View File

@@ -0,0 +1,47 @@
import { FC, useEffect, useState } from "react";
import { v4 } from "uuid";
import { useAuthContext } from "../../context/AuthContext"
import useDateFormat from "../../hooks/useDateFormat";
import { ICollection } from "../../schemas";
import API from "../../util/API";
import { Card } from "../ui"
type CollectionListType = FC<{ targetID?: number | string }>
const CollectionList: CollectionListType = ({ targetID = null }) => {
const [collections, setCollections] = useState<ICollection[]>();
const [author, setAuthor] = useState<string>();
const { user, token } = useAuthContext();
useEffect(() => {
if (user && token) {
if (targetID) {
(async() => {
const Collections = new API.Collection(token);
const result = await Collections.getAllAuthored(targetID);
setCollections(result);
})();
} else {
(async() => {
const Collections = new API.Collection(token);
const result = await Collections.getAllAuthored();
setCollections(result);
})();
}
}
}, [user])
return (
<Card>
{ collections && collections.map(each =>
<div className="collection-item" key={v4()}>
<h2>{each.name}</h2>
{ targetID && <p>Created by {author}</p>}
<p>Created {useDateFormat(each.datecreated)}</p>
</div>
)}
</Card>
)
}
export default CollectionList

View File

@@ -1,58 +1,71 @@
import { useEffect, useState } from "react";
import { v4 } from "uuid";
import { useAuthContext } from "../../context/AuthContext";
import { getAllUsers, getFriendships, getPendingFriendRequests, getUserByID } from "../../util/apiUtils";
import { FC, useEffect, useState } from "react";
import { v4 } from "uuid";
import API from "../../util/API";
import UserCard from "../ui/UserCard";
import { IUser, IFriendship } from "../../schemas";
import { Card, Divider, Panel } from "../ui";
import FriendSearchWidget from "../ui/Widgets/FriendSearchWidget";
export default function Friends() {
const Friends: FC<{ targetUser?: IUser }> = ({ targetUser }) => {
const [friends, setFriends] = useState<IFriendship[]>();
const [userList, setUserList] = useState(new Array<IUser>());
const { user } = useAuthContext();
const { user, token } = useAuthContext();
useEffect(() => {
if (!user) return;
if (!user || !token) return;
(async function() {
try {
const rawResult = await getFriendships();
const Friends = new API.Friendship(token);
const result: IFriendship[] | null = await Friends.getAll();
if (rawResult.length) {
const result = rawResult.filter((item: IFriendship) => (item.senderid == user.id) && !(item.pending));
if (result?.length) {
setFriends(result);
}
} catch(e) {
console.error(e);
console.log(result);
} catch (error) {
console.error(error);
}
})()
}, [user])
})();
}, [])
useEffect(() => {
friends && friends.map(async (friend: IFriendship) => {
const userData = await getUserByID(friend.targetid);
if (!token || !friends) return;
friends.map(async (friend: IFriendship) => {
const User = new API.User(token);
const userData = await User.getByID(friend.targetid as string);
if (userData) setUserList((prev: IUser[]) => {
return [...prev, userData]
if (prev.includes(userData)) {
return prev;
} else {
return [...prev, userData]
}
})
})
}, [friends]);
useEffect(() => {
console.log(userList);
}, [setUserList])
return (
<>
{ userList.length ?
(
<Panel extraStyles="flex-row">
<h2>Your friendships:</h2>
<Card extraClasses="flex-row">
<h2>Friends ({ userList?.length ?? "0" }):</h2>
<div className="friends-list">
{
userList.map((user: IUser) => {
return <UserCard key={v4()} user={user} />
return <UserCard key={v4()} targetUser={user} />
})
}
</Panel>
</div>
<aside>
<p>Looking for someone else?</p>
<p>You can search for more friends <a href="/add-friends">here!</a></p>
</aside>
</Card>
) :
(
<Card>
@@ -68,3 +81,5 @@ export default function Friends() {
</>
)
}
export default Friends

View File

@@ -0,0 +1,195 @@
import { Autocomplete, TextField } from "@mui/material"
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useAuthContext } from "../../context/AuthContext";
import { DropdownData, IIngredient, RecipeIngredient } from "../../schemas";
import { IngredientFieldData } from "../../util/types";
import { Button } from "../ui";
interface IngredientSelectorProps {
position: number
ingredients: IIngredient[]
units: DropdownData[]
getRowState: (input: IngredientFieldData) => void
destroy: (position: number) => void
}
const createIngredient = (name: string, userid: string | number) => {
return {
name: name,
createdbyid: userid
}
}
const quantityOptions: readonly any[] = [
{ name: "1/8" , value: 0.125 },
{ name: "1/4" , value: 0.250 },
{ name: "1/3" , value: 0.333 },
{ name: "3/8" , value: 0.375 },
{ name: "1/2" , value: 0.500 },
{ name: "5/8" , value: 0.625 },
{ name: "2/3" , value: 0.666 },
{ name: "3/4" , value: 0.750 },
{ name: "7/8" , value: 0.875 },
{ name: "1 1/4" , value: 1.250 },
{ name: "1 1/3" , value: 1.333 },
{ name: "1 1/2" , value: 1.500 },
{ name: "1 2/3" , value: 1.666 },
{ name: "1 3/4" , value: 1.750 },
{ name: "2 1/4" , value: 2.250 },
{ name: "2 1/3" , value: 2.333 },
{ name: "2 1/2" , value: 2.500 },
{ name: "2 3/4" , value: 2.750 },
{ name: "3 1/4" , value: 3.250 },
{ name: "3 1/2" , value: 3.500 },
{ name: "3 3/4" , value: 3.750 },
{ name: "4 1/4" , value: 4.250 },
{ name: "4 1/2" , value: 4.500 },
{ name: "4 3/4" , value: 4.750 },
{ name: "5 1/4" , value: 5.250 },
{ name: "5 1/2" , value: 5.500 },
{ name: "5 3/4" , value: 5.750 },
]
function IngredientSelector({ position, ingredients, units, getRowState, destroy }: IngredientSelectorProps) {
const { user } = useAuthContext();
const [ingredientOptions, setIngredientOptions] = useState(ingredients.map(each => each.name));
const [rowState, setRowState] = useState<IngredientFieldData>({
quantity: undefined,
rowPosition: position,
measurement: undefined,
ingredientSelection: undefined,
ingredients: ingredients
})
const [quantityError, setQuantityError] = useState<null | string>(null);
useEffect(() => {
getRowState(rowState);
}, [rowState, setRowState])
function validateQuantity(input: any) {
const value = new Number(input).valueOf();
if (Number.isNaN(value)) {
console.log('is nan');
setQuantityError("Please provide a valid input (number)");
return;
}
if (!input) {
console.log('is null');
setRowState({ ...rowState, quantity: undefined });
setQuantityError(null);
return;
}
setQuantityError(null);
setRowState({ ...rowState, quantity: value });
}
return (
<table className="ingredient-widget"><tbody>
<tr>
<td className="quantity-of-unit">
<Autocomplete
freeSolo
autoHighlight
options={quantityOptions.map(each => each.name)}
className="ui-creatable-component"
onChange={(event, value) => {
console.log(value);
validateQuantity(quantityOptions.filter(option => (option.name) == value)[0]['value'] as number);
}}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
color={(rowState.quantity == null) ? (quantityError ? "error" : "info") : (quantityError ? "error" : "success")}
label={quantityError ?? "Quantity"}
onChange={(e) => validateQuantity(e.target.value)}
/>
)}
/>
</td>
<td className="ingredient-unit">
<Autocomplete
autoHighlight
options={units.map(each => each.name)}
className="ui-creatable-component"
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Unit"
/>
)}
onChange={(event, value) => {
if (value) {
setRowState({ ...rowState, measurement: value });
}
}}
/>
</td>
<td className="ingredient-name">
<Autocomplete
autoHighlight
options={ingredientOptions}
freeSolo
className="ui-creatable-component"
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="Ingredient Name"
/>
)}
onChange={(event, newValue) => {
if (!user) return;
if (typeof newValue == 'string') {
const newIngredient = createIngredient(newValue, user.id!);
setIngredientOptions((prev) => {
let shouldInsert = true;
for (let each of prev) {
if (each == newValue) shouldInsert = false;
}
return (shouldInsert ? [...prev, newValue] : prev);
});
setRowState((prev) => {
let shouldInsert = true;
for (let each of prev.ingredients) {
if (each.name == newValue) shouldInsert = false;
}
return {
...prev,
ingredients: (shouldInsert ? [...prev.ingredients, newIngredient] : [...prev.ingredients]),
ingredientSelection: newValue
}
})
} else if (newValue == null) {
setRowState((prev) => {
return {
...prev,
ingredients: ingredients,
ingredientSelection: undefined
}
})
}
}}
/>
</td>
<td>
<Button onClick={() => destroy(position)}>Close</Button>
</td>
</tr>
</tbody></table>
)
}
export default IngredientSelector

View File

@@ -0,0 +1,22 @@
import Protect from "../../util/Protect"
import { Divider, Panel } from "../ui"
import FriendSearchWidget from "../ui/Widgets/NewFriendWidget"
const AddFriends = () => {
return (
<Protect redirect="/add-friends">
<h1>Search for New Friends</h1>
<Divider />
<Panel>
<h2>Use the widget below to search for new friends!</h2>
<Divider />
<FriendSearchWidget />
</Panel>
</Protect>
)
}
export default AddFriends

View File

@@ -1,60 +1,337 @@
// library/framework
import { useCallback, useEffect, useState } from "react";
import { Autocomplete, TextField } from "@mui/material";
import { v4 } from "uuid";
// util/api
import { useAuthContext } from "../../context/AuthContext";
import { IRecipe } from "../../schemas";
import { Button, Divider, Form, Page, Panel } from "../ui"
import { DropdownData, IIngredient, IRecipe, RecipeIngredient } from "../../schemas";
import { IngredientFieldData } from "../../util/types";
import Protect from "../../util/Protect";
import API from "../../util/API";
const AddRecipe = () => {
const authContext = useAuthContext();
const [input, setInput] = useState<IRecipe>({ name: '', preptime: '', description: '', authoruserid: '', ingredients: [] })
const [form, setForm] = useState<JSX.Element>();
// ui/components
import { Button, Card, Divider, Panel, RichText, Toast } from "../ui"
import IngredientSelector from "../derived/IngredientSelector";
const getFormState = useCallback((data: IRecipe) => {
setInput(data);
}, [input])
export default function AddRecipe() {
/**********************************
* STATE AND CONTEXT
*********************************/
const { user, token } = useAuthContext();
const handleCreate = () => {
// received recipe data
const [input, setInput] = useState<IRecipe>({ name: '', preptime: '', description: '', authoruserid: '' })
// UI state handling
const [ingredients, setIngredients] = useState<IIngredient[]>([]);
const [measurements, setMeasurements] = useState<DropdownData[]>([]);
const [courseData, setCourseData] = useState<DropdownData[]>([]);
const [ingredientFields, setIngredientFields] = useState<Array<JSX.Element>>([]);
const [ingredientFieldData, setIngredientFieldData] = useState<Array<IngredientFieldData>>([]);
const [optionCount, setOptionCount] = useState(0);
// status reporting
const [toast, setToast] = useState(<></>)
/**********************************
* CALLBACKS FOR CHILD COMPONENTS
*********************************/
// callback to retrieve state from ingredient rows
const getRowState = useCallback((value: IngredientFieldData) => {
setIngredientFieldData((prev) => {
const newState = prev;
newState[value.rowPosition] = value;
return newState;
});
}, [ingredientFieldData])
// callback passed to each ingredient row to enable the row to be closed
const destroySelector = useCallback((position: number) => {
setIngredientFieldData((prev) => {
return [...prev.filter(each => each.rowPosition !== position)];
})
setIngredientFields((prev) => {
const newState = new Array<JSX.Element>();
for (let i = 0; i < prev.length; i++) {
if (i === position) {
continue;
} else {
newState.push(prev[i]);
}
}
return newState;
})
}, [ingredientFields]);
/**********************************
* PAGE MOUNT BEHAVIOR AND SIDE EFFECTS
*********************************/
// store all ingredients on page mount
useEffect(() => {
token && (async() => {
const ingredients = new API.Ingredient(token);
const _dropdowns = new API.Dropdowns(token);
const ingredientList = await ingredients.getAll();
const measurementList = await _dropdowns.getAllMeasurements();
const courseList = await _dropdowns.getAllCourses();
function filterDuplicateEntries(receivedList: any[], previousState: any) {
let newEntries = [];
for (let each of receivedList) {
if (previousState.includes(each)) {
continue;
}
newEntries.push(each);
}
return newEntries;
}
if (ingredientList) {
setIngredients((prev) => {
let newEntries = filterDuplicateEntries(ingredientList, prev);
return [...prev, ...newEntries];
});
}
if (measurementList) {
setMeasurements((prev) => {
let newEntries = filterDuplicateEntries(measurementList, prev);
return [...prev, ...newEntries];
});
}
if (courseList && !courseData.length) {
setCourseData((prev) => {
let newEntries = filterDuplicateEntries(courseList, prev);
return [...prev, ...newEntries];
});
}
})();
}, [token])
// mount the ingredient selection section once conditions met:
// 1. ingredients have been fetched
// 2. measurements have been fetched
// 3. ingredient fields have not already been initialized
useEffect(() => {
const conditionsMet = (ingredients.length && measurements.length) && (!ingredientFields.length);
if (conditionsMet) {
setIngredientFields([<IngredientSelector key={v4()} position={optionCount} ingredients={ingredients} units={measurements} getRowState={getRowState} destroy={destroySelector} />]);
}
}, [ingredients, measurements])
/**********************************
* PAGE SPECIFIC FUNCTIONS
*********************************/
// submit handler
async function handleCreate() {
if (!user || !token) return;
let recipeID;
let recipeName;
// initialize API handlers
const recipeAPI = new API.Recipe(token);
const ingredientAPI = new API.Ingredient(token);
// array to aggregate error/success messages
let messages = new Array<string>();
// inject current user id into recipe entry
setInput({ ...input, authoruserid: user.id! });
// verify all required fields are set
for (let field of Object.keys(input)) {
if (!input[field as keyof IRecipe]) return;
// account for an edge case where this state may not have been set yet
if (field == 'authoruserid' as keyof IRecipe) {
continue;
}
if (!input[field as keyof IRecipe]) {
messages.push("Missing required field " + field);
return;
}
}
console.log('good to go!')
// post recipe entry
const result = await recipeAPI.post(input);
if (result) {
recipeID = result.recipe.id;
recipeName = result.recipe.name;
}
let preparedIngredientData = new Array<RecipeIngredient>();
let newIngredientCount = 0;
// check ingredient row for null values; normalize each row's data and insert into array above
for (let row of ingredientFieldData) {
if (!row) continue;
console.log(row);
if (row.ingredientSelection === undefined) {
messages.push("Please ensure you have included ingredient selections for all rows.");
continue;
}
if (!row.quantity) {
messages.push("Please provide a quantity for ingredient " + row.ingredientSelection);
continue;
}
if (!row.measurement) {
messages.push(row.ingredientSelection + " missing required unit of measurement");
continue;
}
/**
* TO DO:
*
* this inner row isn't working correctly just yet
* once the inputs for each row have been validated:
*
* 1. create new ingredient entries for new ingredients
* 2. create ingredient recipe links for all recipes
*/
for (let ing of row.ingredients) {
// filter out recipes that already exist
if (!ingredients.filter(x => x.name == ing.name).includes(ing)) {
console.log(ing.name);
// post the new ingredient to the database
const newEntry = await ingredientAPI.post(ing);
const newID = newEntry.id;
const newIngredientData: RecipeIngredient = {
ingredientid: newID,
recipeid: recipeID ?? null,
name: row.ingredientSelection as string,
quantity: row.quantity,
unit: row.measurement
}
preparedIngredientData.push(newIngredientData);
messages.push(`Successfully created new ingredient: ${ing.name}!`);
console.log(newEntry);
newIngredientCount++;
} else {
const newIngredientData: RecipeIngredient = {
ingredientid: (ingredients.filter(x => x.name == ing.name)[0].id as number),
recipeid: recipeID ?? null,
name: row.ingredientSelection as string,
quantity: row.quantity,
unit: row.measurement
}
preparedIngredientData.push(newIngredientData);
}
// update the ingredient list
setIngredients((prev) => [...prev, ing]);
}
}
// handle recipe post resolve/reject
if (result) {
let recipeIngredientCount = 0;
for (let ing of preparedIngredientData) {
const ok = await recipeAPI.addIngredientToRecipe(ing, recipeID);
if (ok) recipeIngredientCount++;
}
messages.push(`Created recipe ${recipeName} with ${recipeIngredientCount} total ingredients!`)
if (newIngredientCount > 0) {
messages.push(`Successfully created ${newIngredientCount} new ingredients! Thanks for helping us grow.`);
}
setToast(
<Toast>
<p>Created recipe {recipeName} successfully!</p>
<p>View your new recipe <a href={`/recipe/${recipeID}`}>here!</a></p>
</Toast>
)
} else {
setToast(
<Toast variant="fail">
<p>Error creating your recipe</p>
<p>Please refresh the browser window and try again.</p>
</Toast>
)
}
}
useEffect(() => {
authContext.user && setInput((prev: IRecipe) => {
return {
...prev,
authoruserid: authContext.user!.id!
}
})
}, [authContext])
useEffect(() => {
console.log(input);
}, [input])
// logic for inserting a new ingredient row
function handleNewOption() {
setIngredientFields((prev) => [...prev, <IngredientSelector position={optionCount + 1} key={v4()} ingredients={ingredients} units={measurements} getRowState={getRowState} destroy={destroySelector} />])
setOptionCount(prev => prev + 1);
}
/**********************************
* RENDER
*********************************/
return (
<Page>
<Protect redirect="/add-recipe">
<h1>Add a New Recipe</h1>
<Divider />
<Panel>
<Form parent={input} _config={{
parent: "AddRecipe",
keys: ["name", "preptime", "course", "cuisine", "ingredients", "description"],
labels: ["Recipe Name:", "Prep Time:", "Course:", "Cuisine:", "Ingredients:", "Description:"],
dataTypes: ['text', 'text', 'custom picker', 'custom picker', 'custom picker', 'TINYMCE'],
initialState: input,
getState: getFormState,
richTextInitialValue: "<p>Enter recipe details here!</p>"
}} />
<Panel id="create-recipe-panel" extraClasses="ui-form-component width-80">
<div className="form-row">
<label>Recipe Name:</label>
<TextField variant="outlined" label="Recipe Name" onChange={(e) => setInput({ ...input, name: e.target.value })}/>
</div>
{ form || <h2>Loading...</h2> }
<div className="form-row">
<label>Prep Time:</label>
<TextField variant="outlined" label="Prep Time" onChange={(e) => setInput({ ...input, preptime: e.target.value })}/>
</div>
<div className="form-row">
<label>Course:</label>
{ courseData.length &&
<Autocomplete
autoHighlight
options={courseData}
renderInput={(params) => (
<TextField {...params} variant="outlined" label="Course" />
)}
getOptionLabel={(option) => option.name}
/>}
</div>
<Button onClick={() => console.log(ingredientFieldData)}>Ingredient Field Data</Button>
{ ingredients && (
<>
<Card extraClasses="form-row flex-row ingredient-card">
<label id="ingredients-label">Ingredients:</label>
<div className="ingredient-container">
{ ingredientFields }
<Button id="add-ingredient-button" onClick={handleNewOption}>Add Ingredient</Button>
</div>
</Card>
</>
)}
<Divider />
<div className="form-row">
<label id="description-label">Description:</label>
<RichText id="add-ingredient-description" getState={(text) => setInput({ ...input, description: text })} />
</div>
<Button onClick={handleCreate}>Create Recipe!</Button>
<div id="toast">{ toast }</div>
</Panel>
</Page>
</Protect>
)
}
export default AddRecipe;

View File

@@ -1,38 +1,69 @@
import { useAuthContext } from "../../context/AuthContext";
import Protect from "../../util/Protect";
import { useParams } from "react-router-dom";
import { useState } from "react";
import { useEffect, useState } from "react";
import API from "../../util/API";
import { ICollection, IRecipe, IUser } from "../../schemas";
import { v4 } from "uuid";
import { Panel } from "../ui";
const Collection = () => {
const [isDefault, setIsDefault] = useState(true);
const { user } = useAuthContext();
const [data, setData] = useState<ICollection>();
const [owner, setOwner] = useState<IUser>();
const [recipes, setRecipes] = useState<IRecipe[]>();
const [content, setContent] = useState(<></>);
const { user, token } = useAuthContext();
const { id } = useParams();
if (id) {
setIsDefault(false);
}
useEffect(() => {
if (!id) return;
token && (async() => {
const collections = new API.Collection(token);
const users = new API.User(token);
const result: ICollection = await collections.getByID(id);
setData(result);
const allRecipes = await collections.getRecipesFromOne(id);
setRecipes(allRecipes);
const actualUser = await users.getByID(result.ownerid as string);
setOwner(actualUser);
})();
}, [token])
useEffect(() => {
if (user && data && recipes) {
setContent(
<>
<div className="section-header">
<h1>COLLECTION: {data.name}</h1>
{ <p>Collection by: {owner ? `${owner.firstname} ${owner.lastname}` : `${user.firstname} ${user.lastname}`}</p> }
<p>{recipes.length || 0} recipe{recipes.length != 1 && "s" }</p>
{ data.ismaincollection && (
owner ? <p>(This is {owner.firstname}'s main collection)</p> : <p>(This is your main collection)</p>
)}
<Panel>
{
recipes && recipes.map((each: IRecipe) =>
<div className="recipe-card" key={v4()}>
<h2>{each.name}</h2>
{ each.description && <div dangerouslySetInnerHTML={{ __html: each.description }}></div> }
</div>
)
}
</Panel>
</div>
</>
)
}
}, [data, recipes])
return (
<Protect>
{ isDefault ?
<>
<h1>Mikayla's collection</h1>
<p>37 recipes</p>
<p>71 ingredients</p>
<p>11 types of cuisine</p>
</>
:
<>
</>
}
{/* recipes */}
<Protect redirect={`/collections/${id}`}>
{ content }
</Protect>
)
}

View File

@@ -1,10 +1,69 @@
import { Page } from "../ui";
import { useEffect, useState } from "react";
import { v4 } from "uuid";
import { useAuthContext } from "../../context/AuthContext";
import { ICollection } from "../../schemas";
import API from "../../util/API";
import Protect from "../../util/Protect";
import { Page, Panel } from "../ui";
const CollectionBrowser = () => {
const [list, setList] = useState<ICollection[]>();
const { token } = useAuthContext();
async function getRecipeCount(collection: ICollection) {
if (!token) return [];
const collections = new API.Collection(token);
const result = await collections.getRecipesFromOne(collection.id);
if (result) return result;
return [];
}
async function mapRecipes() {
if (!list) return;
return list.map(async (each) => {
const count = await getRecipeCount(each);
return (
<Panel key={v4()}>
<h2>{each.name}</h2>
<p>{count.length} recipes</p>
<a href={`/collections/${each.id}`}>Link to details</a>
</Panel>
)
})
}
useEffect(() => {
if (!token) return;
(async() => {
const collections = new API.Collection(token);
const allRecipes = await collections.getAllAuthored();
if (allRecipes) setList(allRecipes);
})();
}, [token])
useEffect(() => {
}, [list])
return (
<Page>
<h1>Browsing your {2} collections:</h1>
</Page>
<Protect redirect="/collections">
{ list && (
<>
<h1>Browsing your {list.length} collection{ (list.length !== 1) && "s" }:</h1>
{ list.map(each => {
return (
<Panel key={v4()}>
<h2>{each.name}</h2>
<a href={`/collections/${each.id}`}>Link to details</a>
</Panel>
)
})}
</>
)}
</Protect>
)
}

View File

@@ -3,7 +3,7 @@ import { AuthContext, useAuthContext } from "../../context/AuthContext";
import { useNavigate, useParams } from "react-router-dom";
import { IUser, IUserAuth } from "../../schemas";
import { Button, Form, Page, Panel } from "../ui";
import { FormConfig } from "../ui/Form";
import { FormConfig } from "../ui/Form/Form";
import API from "../../util/API";
export default function Login() {
@@ -29,7 +29,7 @@ export default function Login() {
setToken(result.token);
// if there is a redirect, go there, else go home
navigate(`/${redirect ?? ''}`);
navigate(redirect ?? '/');
}
// check for logged in user and mount form
@@ -41,9 +41,9 @@ export default function Login() {
<Page>
<h1>Hello! Nice to see you again.</h1>
<Panel extraStyles="form-panel">
<Panel extraClasses="form-panel">
<Form parent={input} _config={{
<Form<IUserAuth> _config={{
parent: 'login',
keys: Object.keys(input),
labels: ["Email", "Password"],

View File

@@ -1,22 +1,196 @@
import { useContext, useEffect, useState } from "react";
import { IUser } from "../../schemas";
import { useNavigate } from "react-router-dom";
import { AuthContext, useAuthContext } from "../../context/AuthContext";
import { Button, Page } from "../ui";
import { useEffect, useState } from "react";
import { AxiosError } from "axios";
import { useAuthContext } from "../../context/AuthContext";
import API from "../../util/API";
import Protect from "../../util/Protect";
import { ICollection, IUser } from "../../schemas";
import Friends from "../derived/Friends";
import CollectionList from "../derived/CollectionList";
import { Button, Divider, Page, Panel } from "../ui";
import useDateFormat from "../../hooks/useDateFormat";
interface ProfileMetadata {
targetID?: string | number
targetUser?: IUser | undefined
formattedDate: string
collections: ICollection[]
friends: IUser[]
isSet: boolean
}
export default function Profile() {
const [message, setMessage] = useState<JSX.Element>();
const { user } = useAuthContext();
// globals and router utils
const { user, token } = useAuthContext();
const navigate = useNavigate();
return (
<Protect redirect="profile">
<div className="profile-authenticated">
<h1>{user && user.firstname}'s Profile</h1>
<Friends />
</div>
</Protect>
)
// UI state info
const [contents, setContents] = useState<JSX.Element>(<></>);
// master state for this page
const [metadata, setMetadata] = useState<ProfileMetadata>({
targetID: undefined,
targetUser: undefined,
formattedDate: "",
collections: [],
friends: [],
isSet: false
});
// STEP 1: FETCH METADATA (requires token)
useEffect(() => {
if (!token || !user) return;
const params = new URLSearchParams(window.location.search);
const targetID = params.get('id');
// if a target is specified in the url
if (targetID) {
setMetadata((prev: ProfileMetadata) => {
return { ...prev, targetID: targetID }
});
// fetch and store user data with associated user id
(async() => {
try {
const User = new API.User(token);
const result = await User.getByID(targetID);
if (result) {
setMetadata((prev) => {
return {
...prev,
targetUser: result,
formattedDate: useDateFormat(result.datecreated)
}
})
}
} catch (error) {
if (error instanceof AxiosError) {
if (error?.response?.status == 404) {
// to do: replace with customizable 404 page
setContents(
<Page>
<h1>404: Not found</h1>
<Divider />
<Panel>
<p>No user found with ID {targetID}</p>
<Button onClick={() => navigate('/')}>Home</Button>
</Panel>
</Page>
)
return;
}
} else {
console.error(error);
}
}
})();
// do the same for this user's collections and friends
(async() => {
const Collections = new API.Collection(token);
const result = await Collections.getAllAuthored(metadata.targetID);
if (result) {
setMetadata((prev: ProfileMetadata) => {
return {
...prev, collections: result
}
})
}
})();
} else {
// otherwise, this is the current user's profile and should load some slightly different info
(async() => {
const Collections = new API.Collection(token);
const result = await Collections.getAllAuthored();
if (result) {
setMetadata((prev: ProfileMetadata) => {
return {
...prev, collections: result
}
})
}
})();
setMetadata((prev) => {
return {
...prev,
formattedDate: useDateFormat(user.datecreated)
}
})
}
setMetadata((prev) => {
return { ...prev, isSet: true }
})
}, [token]);
// STEP 2: set up page UI based on profile config above
useEffect(() => {
if (metadata.isSet) {
// if this is another user's profile
if (metadata.targetUser) {
setContents(
<Protect redirect="/">
<div className="profile-authenticated">
<h1>{metadata.targetUser.firstname}'s Profile</h1>
<div className="profile-grid">
<Panel>
<h2>About {metadata.targetUser.firstname} {metadata.targetUser.lastname}:</h2>
<p>Recipin Member since: {metadata.formattedDate}</p>
</Panel>
<Panel>
<h2>{metadata.targetUser.firstname}'s collections ({ metadata.collections.length ?? "0" }):</h2>
<CollectionList targetID={metadata.targetUser.id} />
</Panel>
<Panel>
<h2>{metadata.targetUser.firstname}'s friends:</h2>
<Friends />
</Panel>
</div>
</div>
</Protect>
)
} else {
// if this is the current user's profile
setContents(
<Protect redirect="/profile">
<div className="profile-authenticated">
<h1>{user!.firstname}'s Profile</h1>
<div className="profile-grid">
<Panel>
<h2>About me:</h2>
<p>{user!.firstname} {user!.lastname}</p>
<p>Recipin Member since: {metadata.formattedDate}</p>
<Divider />
<p>30 recipes</p>
<p>2 collections</p>
</Panel>
<Panel>
{/* include number of collections */}
<h2><a href="/collections">My collections</a> ({ metadata.collections.length || 0 }):</h2>
<CollectionList />
</Panel>
<Panel>
<Friends />
</Panel>
</div>
</div>
</Protect>
)
}
}
}, [metadata])
// STEP 3: mount the UI
return contents
}

View File

@@ -1,36 +1,109 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Page, Panel } from "../ui";
import { IRecipe } from "../../util/types";
import { Divider, Page, Panel } from "../ui";
import { IRecipe, IUser, IIngredient, RecipeIngredient } from "../../schemas";
import { getRecipeByID } from "../../util/apiUtils";
import Protect from "../../util/Protect";
import API from "../../util/API";
import { useAuthContext } from "../../context/AuthContext";
import ResourceNotFound from "./StatusPages/404";
import { v4 } from "uuid";
export default function Recipe() {
const [recipe, setRecipe] = useState<IRecipe>();
const { user, token } = useAuthContext();
const { id } = useParams();
if (!id) {
return (
<Page>
<h1>404 | Not Found</h1>
<p>There's no content here! Technically, you shouldn't have even been able to get here.</p>
<p>So, kudos, I guess!</p>
</Page>
)
}
const [recipe, setRecipe] = useState<IRecipe | "no recipe">();
const [userData, setUserData] = useState<IUser>();
const [ingredientData, setIngredientData] = useState<RecipeIngredient[]>([]);
const [view, setView] = useState<JSX.Element>(<h1>Loading...</h1>);
useEffect(() => {
getRecipeByID(id);
}, [])
if (token && id) {
(async() => {
const recipeAPI = new API.Recipe(token);
const result = await recipeAPI.getByID(id);
if (result) {
setRecipe(result);
} else {
setRecipe("no recipe");
}
})()
}
}, [token])
return (
<Page>
{ recipe && (
<Panel>
useEffect(() => {
if (recipe && id) {
if (recipe === "no recipe") {
setView(<ResourceNotFound><h2>We couldn't find a recipe with the ID {id}.</h2></ResourceNotFound>);
} else {
if (!user || !token) return;
// while ingredient data has not yet been mapped,
// get ingredients and map them
(!ingredientData.length) && (async() => {
const ingredientAPI = new API.Ingredient(token);
const result = await ingredientAPI.getAllForRecipe(id);
setIngredientData(result);
})();
const selfAuthored = (recipe.authoruserid == user.id!);
if (selfAuthored) {
setUserData(user);
} else {
(async() => {
const userAPI = new API.User(token);
const foundUser = await userAPI.getByID(recipe.authoruserid as string);
setUserData(foundUser);
})();
}
}
}
}, [recipe, id])
useEffect(() => {
if (!userData) return;
if (recipe && ingredientData && recipe !== "no recipe") {
setView(
<Protect redirect={`/recipe/${id}`}>
<h1>{recipe.name}</h1>
<p>{recipe.description}</p>
<p>{recipe.preptime}</p>
</Panel>
)}
</Page>
)
<h2>Provided courtesy of {userData.firstname} {userData.lastname}</h2>
<Divider />
<p>Prep time: {recipe.preptime}</p>
<Divider />
<h2>Ingredients:</h2>
{ ingredientData.length
? ingredientData.map((each: RecipeIngredient) => (
<div key={v4()}>
<p>{each.quantity} {each.quantity == 1 ? each.unit : (each.unit + "s")} {each.name}</p>
</div>
)) : <p>No ingredients for this recipe</p>
}
<Divider />
<div dangerouslySetInnerHTML={{ __html: (recipe.description || "")}}></div>
</Protect>
);
}
}, [userData, recipe, ingredientData, id]);
useEffect(() => {
if (!ingredientData.length || !token) return;
for (let each of ingredientData) {
(async() => {
const ingredientAPI = new API.Ingredient(token);
const result = await ingredientAPI.getByID(each.ingredientid!.toString());
console.log(result);
})();
}
}, [ingredientData, token])
return view
}

View File

@@ -1,14 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { v4 } from "uuid";
import { RegisterVariantType, VariantLabel } from ".";
import { useAuthContext } from "../../../context/AuthContext";
import { IUser, IUserAuth } from "../../../schemas";
import { attemptLogin, attemptRegister } from "../../../util/apiUtils";
import { IUser } from "../../../schemas";
import API from "../../../util/API";
import { Button, Page, Panel } from "../../ui";
import Divider from "../../ui/Divider";
import Form from "../../ui/Form";
import Form from "../../ui/Form/Form";
const blankUser: IUser = {
firstname: '',
@@ -54,9 +52,9 @@ const AboutYou: RegisterVariantType = ({ transitionDisplay }) => {
<h2>Tell us a bit about yourself:</h2>
<Panel extraStyles="form-panel two-columns">
<Panel extraClasses="form-panel two-columns">
<Form parent={input} _config={{
<Form<IUser> _config={{
parent: "register",
keys: ['firstname', 'lastname', 'handle', 'email', 'password'],
initialState: input,

View File

@@ -1,11 +1,9 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { RegisterVariantType, VariantLabel } from ".";
import { useNow } from "../../../hooks/useNow";
import { ICollection, IUser, IUserAuth } from "../../../schemas";
import { attemptLogin, createNewCollection } from "../../../util/apiUtils";
import { ICollection } from "../../../schemas";
import API from "../../../util/API";
import { Button, Divider, Page, Panel } from "../../ui";
import TextField from "../../ui/TextField";
import { useAuthContext } from "../../../context/AuthContext";
const InitialCollection: RegisterVariantType = ({ transitionDisplay, input }) => {
@@ -28,10 +26,7 @@ const InitialCollection: RegisterVariantType = ({ transitionDisplay, input }) =>
datemodified: now
}
console.log(collection);
const result = await collectionAPI.post(collection);
console.log(result);
if (result) transitionDisplay(VariantLabel.AddFriends);
}
@@ -50,7 +45,7 @@ const InitialCollection: RegisterVariantType = ({ transitionDisplay, input }) =>
<h3>What would you like to call your main collection?</h3>
{/* <TextField onChange={(e: ChangeEvent<HTMLInputElement>) => setCollectionName(e.target.value)} placeholder={user.firstname + 's Collection'} /> */}
<input type="text" onChange={(e) => setCollectionName(e.target.value)} placeholder={user.firstname + 's Collection'}></input>
<input type="text" onChange={(e) => setCollectionName(e.target.value)} placeholder={user.firstname + '\'s Collection'}></input>
</Panel>
<Button onClick={handleClick}>Next</Button>

View File

@@ -1,4 +1,4 @@
import { FC, useEffect, useState } from "react";
import { FC, useState } from "react";
import { useAuthContext } from "../../../context/AuthContext";
import { IUser } from "../../../schemas";
import AboutYou from "./aboutyou";

View File

@@ -0,0 +1,16 @@
import { useNavigate } from "react-router-dom";
import { Button, Divider, Page } from "../../ui";
export default function AccessForbidden({ children = <></> }) {
const navigate = useNavigate();
return (
<Page>
<h1>403: Unauthorized</h1>
{ children }
<Divider />
<Button onClick={() => navigate('/')}>Home</Button>
</Page>
)
}

View File

@@ -0,0 +1,16 @@
import { useNavigate } from "react-router-dom";
import { Button, Divider, Page } from "../../ui";
export default function ResourceNotFound({ children = <></> }) {
const navigate = useNavigate();
return (
<Page>
<h1>404: We didn't find what you are looking for</h1>
{ children }
<Divider />
<Button onClick={() => navigate('/')}>Home</Button>
</Page>
)
}

View File

@@ -1,16 +1,14 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthContext } from "../../context/AuthContext";
import { attemptLogout, checkCredientials } from "../../util/apiUtils";
import { Button, Page, Panel } from "../ui"
import Divider from "../ui/Divider";
const Welcome = () => {
const navigate = useNavigate();
const { user, setUser } = useAuthContext();
const { user } = useAuthContext();
const authUserActions = (
<Panel extraStyles="inherit-background c-papyrus uppercase flexrow">
<Panel extraClasses="inherit-background c-papyrus uppercase flexrow">
<Button onClick={() => navigate('/explore')}>Browse Recipes</Button>
<Button onClick={() => navigate('/subscriptions')}>Subscriptions</Button>
<Button onClick={() => navigate('/grocery-list')}>Grocery Lists</Button>
@@ -18,30 +16,28 @@ const Welcome = () => {
)
const callToRegister = (
<Panel extraStyles="inherit-background c-papyrus uppercase">
<Panel extraClasses="inherit-background c-papyrus uppercase">
<h2>Ready to get started?</h2>
<Button onClick={() => navigate('/register')}>Register</Button>
<Button onClick={attemptLogout}>Log Out</Button>
</Panel>
)
return (
<Page extraStyles="narrow-dividers">
<Panel extraStyles='inherit-background c-papyrus uppercase'>
<Page extraClasses="narrow-dividers">
<Panel extraClasses='inherit-background c-papyrus uppercase'>
<h1>Welcome to Recipin</h1>
</Panel>
<Divider />
<Panel extraStyles="inherit-background c-papyrus uppercase">
<Panel extraClasses="inherit-background c-papyrus uppercase">
<h2>Simple Recipe Management and Sharing for the Home</h2>
</Panel>
<Divider />
<Panel extraStyles="inherit-background c-papyrus uppercase">
<Panel extraClasses="inherit-background c-papyrus uppercase">
<h2>Build Shopping Lists Directly from Your Recipes</h2>
<button onClick={checkCredientials}></button>
</Panel>
<Divider />

View File

@@ -1,6 +1,7 @@
import { FC, useEffect, useState } from "react";
import { useAuthContext } from "../../context/AuthContext";
import Protect from "../../util/Protect";
import Form from "./Form";
import Form from "./Form/Form";
interface BrowserProps {
children?: JSX.Element[]
@@ -9,6 +10,8 @@ interface BrowserProps {
}
const Browser: FC<BrowserProps> = ({ children, header, searchFunction }) => {
const { user, token } = useAuthContext();
const [form, setForm] = useState<any>();
useEffect(() => {
@@ -16,7 +19,7 @@ const Browser: FC<BrowserProps> = ({ children, header, searchFunction }) => {
})
return (
<Protect>
<Protect redirect="/explore">
<h1>{header}</h1>
</Protect>
)

View File

@@ -1,9 +1,17 @@
import { ButtonComponent } from "../../util/types"
import "/src/sass/components/Button.scss";
const Button: ButtonComponent = ({ onClick = (() => {}), children, extraStyles, disabled = false, disabledText = null }) => {
const Button: ButtonComponent = ({ onClick = (() => {}), children, extraClasses, id = null, disabled = false, disabledText = null }) => {
if (id?.length) {
return (
<button id={id} onClick={onClick} disabled={disabled} className={`ui-button ${extraClasses || ''}`}>
{ disabled ? (disabledText || children || "Button") : (children || "Button") }
</button>
)
}
return (
<button onClick={onClick} disabled={disabled} className={`ui-button ${extraStyles || ''}`}>
<button onClick={onClick} disabled={disabled} className={`ui-button ${extraClasses || ''}`}>
{ disabled ? (disabledText || children || "Button") : (children || "Button") }
</button>
)

View File

@@ -1,10 +1,10 @@
import { FC } from "react"
import { MultiChildPortal } from "../../util/types"
import { FC } from "react";
const Card: FC<MultiChildPortal> = ({ children = <></>, extraStyles = ""}) => {
const Card: FC<{ children?: JSX.Element | JSX.Element[], extraClasses?: string }> = ({ children = <></>, extraClasses = ""}) => {
return (
<div className={`ui-card ${extraStyles}`}>
{ children }
<div className={`ui-card ${extraClasses}`}>
{ Array.isArray(children) ? <>{children}</> : children }
</div>
)
}

View File

@@ -4,9 +4,9 @@ import { PortalBase } from "../../util/types";
import "/src/sass/components/Dropdown.scss";
// expects to receive buttons as children
const Dropdown: FC<PortalBase> = ({ children, extraStyles = null }) => {
const Dropdown: FC<PortalBase> = ({ children, extraClasses = null }) => {
return (
<Panel extraStyles={`ui-dropdown ${extraStyles}`}>
<Panel extraClasses={`ui-dropdown ${extraClasses}`}>
{ children }
</Panel>
)

View File

@@ -1,107 +0,0 @@
import { ChangeEvent, FC, useEffect, useState } from "react"
import { v4 } from "uuid"
import RichText from "./RichText"
export interface FormConfig<T> {
parent: string
keys: string[]
initialState: T
getState: (received: T) => void
labels?: string[]
dataTypes?: string[]
richTextInitialValue?: string
extraStyles?: string
}
interface FormProps {
parent: any
_config: FormConfig<any>
}
const Form: FC<FormProps> = ({ parent, _config }) => {
type T = typeof parent;
const { getState } = _config;
const [config, setConfig] = useState<FormConfig<T>>();
const [state, setState] = useState<T>();
const [contents, setContents] = useState<JSX.Element[]>();
// initial setup
useEffect(() => {
if (!config) setConfig({
..._config,
labels: _config.labels ?? _config.keys,
dataTypes: _config.dataTypes ?? new Array(_config.keys?.length).fill("text"),
});
if (!state) setState(_config.initialState);
}, [])
// usecallback handling
useEffect(() => {
state && getState(state);
}, [state]);
// update methods
function updateRichText(txt: string, idx: number) {
if (!config) return;
setState((prev: T) => {
return {
...prev,
[config.keys[idx]]: txt
}
})
}
function update(e: ChangeEvent<HTMLElement>, idx: number) {
if (!config) return;
setState((prev: T) => {
return {
...prev,
[config.keys[idx]]: e.target['value' as keyof EventTarget]
}
})
}
// mount the form once config has been loaded
useEffect(() => {
if (state && config) {
const result = config.keys.map((each: string, i: number) => {
if (config.dataTypes![i] == 'TINYMCE') {
return (
<div id={`${config.parent}-row-${i}`} key={v4()}>
<label htmlFor={`${config.parent}-${each}`}>{config.labels![i]}</label>
<RichText id={`${config.parent}-${each}`} initialValue={config.richTextInitialValue} getState={(txt) => updateRichText(txt, i)} />
</div>
)
} else {
return (
<div id={`${config.parent}-row-${i}`} key={v4()}>
<label htmlFor={`${config.parent}-${each}`}>{config.labels![i]}</label>
<input
type={config.dataTypes![i]}
id={`${config.parent}-${each}`}
onChange={(e) => update(e, i)}
value={state[i as keyof T] as string}>
</input>
</div>
)
}
});
setContents(result);
}
}, [config]);
return (
<div className={`ui-form-component ${_config.extraStyles}`}>
{ contents }
</div>
)
}
export default Form;

View File

@@ -0,0 +1,141 @@
import { ChangeEvent, FC, useEffect, useState } from "react"
import { v4 } from "uuid"
import { useAuthContext } from "../../../context/AuthContext";
import { useSelectorContext } from "../../../context/SelectorContext";
import SelectorProvider from "../../../context/SelectorProvider";
import { IIngredient, IUser } from "../../../schemas";
import API from "../../../util/API";
import RichText from "../RichText"
import Selector from "../Selector";
import "/src/sass/components/Form.scss";
export interface FormConfig<T> {
parent: string
keys: string[]
initialState: T
getState: (received: T) => void
labels?: string[]
dataTypes?: string[]
richTextInitialValue?: string
extraClasses?: string
selectorInstance?: JSX.Element
}
interface FormProps {
_config: FormConfig<any>
}
function Form<T>({ _config }: FormProps) {
const { getState } = _config;
const [config, setConfig] = useState<FormConfig<T>>();
const [state, setState] = useState<T>(_config.initialState);
const [contents, setContents] = useState<JSX.Element[]>();
const { token } = useAuthContext();
// initial setup
useEffect(() => {
setConfig({
..._config,
labels: _config.labels ?? _config.keys,
dataTypes: _config.dataTypes ?? new Array(_config.keys?.length).fill("text"),
});
if (!state) setState(_config.initialState);
}, [])
// usecallback handling
useEffect(() => {
state && getState(state);
}, [state]);
// update methods
function updateRichText(txt: string, idx: number) {
if (!config) return;
setState((prev: T) => {
return {
...prev,
[config.keys[idx]]: txt
}
})
}
function update(e: ChangeEvent<HTMLElement>, idx: number) {
if (!config) return;
setState((prev: T) => {
return {
...prev,
[config.keys[idx]]: e.target['value' as keyof EventTarget]
}
})
}
async function populateSelector(key: string): Promise<any[] | null> {
if (!token) return null;
switch (key) {
case "ingredient":
const ingredients = new API.Ingredient(token);
const result = await ingredients.getAll();
if (result) return result;
break;
default:
break;
}
return [];
}
// mount the form once config has been loaded
useEffect(() => {
if (state && config) {
(async() => {
const result = config.keys.map(async (each: string, i: number) => {
if (config.dataTypes![i] == 'TINYMCE') {
return (
<div className="form-row-editor" id={`${config.parent}-row-${i}`} key={v4()}>
<label htmlFor={`${config.parent}-${each}`}>{config.labels![i]}</label>
<RichText id={`${config.parent}-${each}`} initialValue={config.richTextInitialValue} getState={(txt) => updateRichText(txt, i)} />
</div>
)
} else if (config.dataTypes![i] == 'SELECTOR') {
if (!config.selectorInstance) throw new Error("Dropdown was not provided to form component.")
return (
<div className="form-row" id={`${config.parent}-row-${i}`} key={v4()}>
<label htmlFor={`${config.parent}-${each}`}>{config.labels![i]}</label>
{ config.selectorInstance }
</div>
)
} else {
return (
<div className="form-row" id={`${config.parent}-row-${i}`} key={v4()}>
<label htmlFor={`${config.parent}-${each}`}>{config.labels![i]}</label>
<input
type={config.dataTypes![i]}
id={`${config.parent}-${each}`}
onChange={(e) => update(e, i)}
value={state[i as keyof T] as string}>
</input>
</div>
)
}
});
const mappedContents = await Promise.all(result);
mappedContents && setContents(mappedContents);
})();
}
}, [config]);
return (
<div className={`ui-form-component ${_config.extraClasses ?? ""}`}>
{ contents }
</div>
)
}
export default Form;

View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { v4 } from "uuid";
interface FormRowConfig {
parent: any
labelText: string
idx?: number
dataType?: string
}
function FormRow({ parent, labelText, idx, dataType = "string" }) {
const [row, setRow] = useState<JSX.Element>();
useEffect(() => {
switch (dataType) {
case "TINYMCE":
break;
case "string":
default:
setRow(
<div className="form-row" id={`${parent}-row-${idx || v4()}`} key={v4()}>
<label htmlFor={`${parent}-${each}`}>{labelText}</label>
<input
type={dataType}
id={`${parent}-${each}`}
onChange={(e) => update(e, idx)}
value={state[i as keyof T] as string}>
</input>
</div>
)
break;
}
}, [])
return (
<></>
)
}

View File

View File

@@ -1,7 +1,6 @@
import API from "../../../util/API";
import { NavbarType } from "../../../util/types";
import { Button, Dropdown } from '..'
import { useEffect, useState } from "react";
import { useState } from "react";
import { useAuthContext } from "../../../context/AuthContext";
import { useNavigate } from "react-router-dom";
@@ -14,8 +13,7 @@ const LoggedIn = () => {
const [searchActive, setSearchActive] = useState(false);
const handleLogout = async () => {
const success = await auth.logout();
console.log(success);
await auth.logout();
// nullify cookie and unset user/token data
document.cookie = `token=;expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
@@ -40,10 +38,6 @@ const LoggedIn = () => {
navigate(payload);
}
useEffect(() => {
console.log(user);
}, [])
return (
<div>
<div id="navbar">
@@ -59,8 +53,9 @@ const LoggedIn = () => {
</div>
{
dropdownActive && (
<Dropdown extraStyles="top-menu-bar actions-bar">
<Dropdown extraClasses="top-menu-bar actions-bar">
<Button onClick={() => handleOptionSelect('/add-recipe')}>Add a Recipe</Button>
<Button onClick={() => handleOptionSelect("/add-friends")}>Add Friends</Button>
<Button onClick={() => handleOptionSelect('/collections')}>My Collections</Button>
<Button onClick={() => handleOptionSelect('/subscriptions')}>Subscriptions</Button>
<Button onClick={() => handleOptionSelect('/profile')}>Profile</Button>
@@ -70,7 +65,7 @@ const LoggedIn = () => {
}
{
searchActive && (
<Dropdown extraStyles="top-menu-bar search-bar">
<Dropdown extraClasses="top-menu-bar search-bar">
<Button>Run Search</Button>
</Dropdown>
)

View File

@@ -5,10 +5,10 @@ import { PageComponent } from "../../util/types"
import Navbar from "./Navbar";
import "/src/sass/components/Page.scss";
const Page: PageComponent = ({ extraStyles, children }) => {
const Page: PageComponent = ({ extraClasses, children }) => {
return (
<main id="view">
<section className={`Page ${extraStyles || null}`}>
<section className={`Page ${extraClasses || null}`}>
{ children || null }
</section>
</main>

View File

@@ -1,12 +1,20 @@
import { PanelComponent } from "../../util/types";
import "/src/sass/components/Panel.scss";
const Panel: PanelComponent = ({ children, extraStyles }) => {
return (
<div className={`Panel ${extraStyles || ''}`}>
{ children || null }
</div>
)
const Panel: PanelComponent = ({ children, extraClasses, id }) => {
if (id) {
return (
<div id={id} className={`Panel ${extraClasses || ''}`}>
{ children || null }
</div>
)
} else {
return (
<div className={`Panel ${extraClasses || ''}`}>
{ children || null }
</div>
)
}
}
export default Panel;

View File

@@ -25,7 +25,7 @@ const RichText: FC<RichTextProps> = ({ id, initialValue, getState }) => {
onEditorChange={(txt, editor) => handleChange(txt, editor)}
initialValue={initialValue || '<p></p>'}
init={{
height: 500,
height: 300,
menubar: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',

View File

@@ -0,0 +1,23 @@
import { FormConfig } from "./Form/Form"
interface OptionType {
value: number
label: string
}
interface SelectorProps<T> {
config: FormConfig<T>
idx: number
update: (e: any, idx: number) => void
optionList: Array<OptionType>
loader?: Array<T>
}
function Selector<T>({ config, idx, update, optionList }: SelectorProps<T>) {
return (
<>
</>
)
}
export default Selector

View File

@@ -0,0 +1,19 @@
import { FC } from "react";
import "/src/sass/components/Toast.scss";
type StyleVariant = "success" | "fail" | "warning"
interface ToastProps {
children: JSX.Element | JSX.Element[]
variant?: StyleVariant
}
const Toast: FC<ToastProps> = ({ children, variant = "success" }) => {
return (
<div className={`ui-component-toast toast-variant-${variant}`}>
{ children }
</div>
)
}
export default Toast;

View File

@@ -1,9 +1,9 @@
import { FC } from "react";
import { PortalBase } from "../../util/types";
const Tooltip: FC<PortalBase> = ({ children, extraStyles = null }) => {
const Tooltip: FC<PortalBase> = ({ children, extraClasses = null }) => {
return (
<aside className={`ui-tooltip ${extraStyles}`}>
<aside className={`ui-tooltip ${extraClasses}`}>
{ children }
</aside>
)

View File

@@ -1,40 +1,57 @@
import { AxiosError } from "axios";
import { useEffect, useState } from "react";
import { addFriend, getPendingFriendRequests } from "../../util/apiUtils";
import { useAuthContext } from "../../context/AuthContext";
import { UserCardType } from "../../util/types";
import API from "../../util/API";
import Button from "./Button";
import Card from "./Card";
const UserCard: UserCardType = ({ extraStyles, user, canAdd = false, liftData }) => {
const [shouldDisable, setShouldDisable] = useState<boolean>(canAdd);
const UserCard: UserCardType = ({ extraClasses, targetUser }) => {
const [buttonVariant, setButtonVariant] = useState(<></>);
const { token } = useAuthContext();
useEffect(() => {
(async function() {
const requestsOpen = await getPendingFriendRequests();
if (!requestsOpen) return;
if (!token) return;
for (let req of requestsOpen) {
if (req.targetid == user.id) {
setShouldDisable(true);
return;
(async function() {
try {
const friends = new API.Friendship(token);
const requestsOpen = await friends.getPendingFriendRequests();
if (!requestsOpen) return;
for (let req of requestsOpen) {
if (req.targetid == targetUser.id) {
setButtonVariant(<Button disabled>Request Sent!</Button>)
return;
}
}
setButtonVariant(<Button onClick={handleClick}>Send Request</Button>)
} catch (error) {
if (error instanceof AxiosError) {
console.log(error.response?.statusText);
}
}
setShouldDisable(false);
})();
}, [])
const handleClick = async () => {
const { id } = user;
const request = await addFriend(id!.toString());
if (request) console.log("Friend request sent to " + user.firstname);
if (!token) return;
const friends = new API.Friendship(token);
const request = await friends.addFriend(targetUser.id!.toString());
if (request) {
setButtonVariant(<Button disabled>Request Sent!</Button>)
}
}
return (
<Card extraStyles={'user-card' + extraStyles}>
<Card extraClasses={'user-card' + extraClasses}>
<>
<div className="avatar"></div>
<h3>{user.firstname} {user.lastname.substring(0,1)}.</h3>
<h4>@{user.handle}</h4>
{ canAdd && <Button disabledText={"Request Sent"} disabled={shouldDisable} onClick={handleClick}>Add Me</Button> }
<h3><a href={`/profile?id=${targetUser.id}`}>{targetUser.firstname} {targetUser.lastname.substring(0,1)}.</a></h3>
<h4>@{targetUser.handle}</h4>
{ buttonVariant }
</>
</Card>
)
}

View File

@@ -0,0 +1,60 @@
import { ChangeEvent, FC, useCallback, useEffect, useState } from "react";
import { IUser } from "../../../schemas";
import { TextField, UserCard } from "..";
import { v4 } from "uuid";
import API from "../../../util/API";
import { useAuthContext } from "../../../context/AuthContext";
const FriendSearchWidget: FC<{}> = () => {
const { token } = useAuthContext();
const [searchTerm, setSearchTerm] = useState<string>();
const [userPool, setUserPool] = useState<IUser[]>([]);
const [pendingRequests, setPendingRequests] = useState();
const [friendResults, setFriendResults] = useState<IUser[]>([]);
// load available user pool on mount
useEffect(() => {
if (!token) return;
(async function() {
const users = new API.User(token);
const result = await users.getAll();
if (result) setUserPool(result);
})();
(async function() {
const friends = new API.Friendship(token);
const result = await friends.getAll();
setFriendResults(result);
})();
}, [])
useEffect(() => {
console.log(searchTerm);
searchTerm && setUserPool((prev) => {
const newPool = prev.filter(person => {
if (person.firstname.toLowerCase().includes(searchTerm) || person.lastname.toLowerCase().includes(searchTerm) || person.handle.toLowerCase().includes(searchTerm)) return person;
})
return newPool;
})
}, [searchTerm])
useEffect(() => {
console.log(userPool);
}, [userPool])
return (
<div id="friend-search-widget">
<TextField onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value.toLowerCase())} placeholder={'Search'} />
{
userPool.map((friend: IUser) => {
return <UserCard key={v4()} user={friend} canAdd liftData={() => {}} />
})
}
</div>
)
}
export default FriendSearchWidget;

View File

@@ -2,14 +2,16 @@ import Button from "./Button";
import Card from "./Card";
import Divider from "./Divider";
import Dropdown from "./Dropdown";
import Form from "./Form";
import Form from "./Form/Form";
import Navbar from "./Navbar";
import Page from "./Page";
import Panel from "./Panel";
import RichText from "./RichText";
import TextField from "./TextField";
import Toast from "./Toast";
import Tooltip from "./Tooltip";
import UserCard from "./UserCard";
export {
Button, Card, Dropdown, Divider, Form, Navbar, Page, Panel, TextField, Tooltip, UserCard
Button, Card, Dropdown, Divider, Form, Navbar, Page, Panel, RichText, TextField, Toast, Tooltip, UserCard
}

View File

@@ -0,0 +1,38 @@
import { createContext, Dispatch, SetStateAction, useContext } from "react"
import { OptionType } from "../util/types"
interface SelectorContextProps<T> {
data: Array<T>
setData: Dispatch<SetStateAction<Array<T>>> | VoidFunction
selected: Array<string>
setSelected: Dispatch<SetStateAction<Array<string>>> | VoidFunction
options: Array<OptionType>
setOptions: Dispatch<SetStateAction<Array<OptionType>>> | VoidFunction
selector: JSX.Element
setSelector: Dispatch<SetStateAction<JSX.Element>> | VoidFunction
onChange: (...params: any) => void
onCreateOption: (label: string, generateObject: (label: string, id: number) => T) => void
}
const defaultValue: SelectorContextProps<any> = {
data: new Array<any>(),
setData: () => {},
selected: new Array<string>(),
setSelected: () => {},
options: new Array<OptionType>(),
setOptions: () => {},
selector: <></>,
setSelector: () => {},
onChange: () => {},
onCreateOption: (label: string) => {},
}
export function createOptionFromText(label: string, value?: number): OptionType {
return {
value: value || 0,
label: label,
}
}
export const SelectorContext = createContext<SelectorContextProps<any>>(defaultValue);
export const useSelectorContext = () => useContext(SelectorContext);

View File

@@ -0,0 +1,40 @@
import { useState } from "react";
import { OptionType } from "../util/types";
import { SelectorContext } from "./SelectorContext";
function SelectorProvider<T>({ children }: { children: JSX.Element | JSX.Element[] }) {
const [data, setData] = useState<Array<T>>([]);
const [selector, setSelector] = useState<JSX.Element>(<></>)
const [options, setOptions] = useState<Array<OptionType>>([]);
const [selected, setSelected] = useState<Array<string>>([]);
/**
* Event handler for a change in selection state
*/
const onChange = (data: Array<any>) => {
setSelected((prev) => [...prev, ...data]);
}
const onCreateOption = (label: string, generateObject: (label: string, id: number) => T) => {
const newID = options.length + 1;
const newOption: OptionType = { label: label, value: newID }
setOptions((prev) => [...prev, newOption]);
setSelected((prev) => [...prev, newOption.label]);
setData((prev) => [...prev, generateObject(label, newID)]);
}
const providerValue = {
data, setData, selector, setSelector,
options, setOptions, selected, setSelected,
onChange, onCreateOption
}
return (
<SelectorContext.Provider value={ providerValue }>
{ children }
</SelectorContext.Provider>
)
}
export default SelectorProvider;

View File

@@ -0,0 +1,10 @@
function useDateFormat(input: string | undefined) {
if (typeof input == 'undefined') return "unknown";
const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: "long" });
const output = dateFormatter.format(new Date(input));
return output;
}
export default useDateFormat

View File

@@ -2,12 +2,15 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import AuthProvider from './context/AuthProvider'
import SelectorProvider from './context/SelectorProvider'
import './sass/index.scss'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<AuthProvider>
<React.StrictMode>
<App />
</React.StrictMode>
<SelectorProvider>
<React.StrictMode>
<App />
</React.StrictMode>
</SelectorProvider>
</AuthProvider>
)

View File

@@ -0,0 +1,84 @@
.ui-form-component {
display: flex;
flex-direction: column;
align-self: center;
width: 75%;
.form-row {
display: inline-flex;
justify-content: flex-start;
align-items: center;
padding: 4px;
margin-bottom: 6px;
label {
text-align: left;
width: 15%;
}
input {
width: 85%;
}
select {
width: 85%;
}
.MuiFormControl-root {
width: 85%;
}
.MuiAutocomplete-root {
width: 100%;
}
#ingredients-label, #description-label {
align-self: flex-start;
padding-top: 1rem;
}
// special properties for form containers on AddRecipe page
.ingredient-container {
width: 85%;
.ingredient-widget {
width: 100%;
tr {
width: 100%;
td {
width: 30%;
.MuiFormControl-root {
width: 100%;
}
label {
width: unset;
}
}
td:last-child {
width: 10%;
}
}
}
#add-ingredient-button {
margin-top: 1rem;
float: right;
}
}
}
.form-row-editor {
display: inline-flex;
flex-direction: column;
text-align: left;
justify-content: flex-start;
label {
padding-bottom: 8px;
}
}
}

View File

@@ -0,0 +1,18 @@
.ui-component-toast {
position: absolute;
bottom: 1rem;
right: 1rem;
padding: 1rem;
.success {
background-color: green;
}
.fail {
background-color: red;
}
.warning {
background-color: yellow;
}
}

View File

@@ -24,4 +24,8 @@
flex-flow: row wrap;
justify-content: center;
}
&.width-80 {
width: 80vw;
}
}

View File

@@ -0,0 +1,8 @@
#create-recipe-panel {
.ingredient-card {
.ingredient-container {
background-color: blue;
width: 75%;
}
}
}

View File

@@ -1,8 +1,15 @@
import { AxiosHeaders, AxiosRequestHeaders } from "axios";
import { IUser, IUserAuth, IFriendship, IRecipe, IIngredient, ICollection, IGroceryList } from "../schemas";
import { AxiosError, AxiosHeaders, AxiosRequestHeaders, AxiosResponse } from "axios";
import { IUser, IUserAuth, IFriendship, IRecipe, IIngredient, ICollection, IGroceryList, DropdownData, RecipeIngredient } from "../schemas";
import { default as _instance } from "./axiosInstance";
module API {
export enum CRUDMETHOD {
GET,
PUT,
POST,
DELETE
}
export class Settings {
private static APISTRING = import.meta.env.APISTRING || "http://localhost:8080";
private static token?: string;
@@ -23,7 +30,7 @@ module API {
abstract class RestController<T> {
protected instance = _instance;
protected endpoint: string;
protected headers?: any
protected headers?: any;
constructor(endpoint: string, token: string) {
this.endpoint = endpoint;
@@ -35,30 +42,80 @@ module API {
};
}
protected async customRoute(method: CRUDMETHOD, path: string, data?: any, requireHeaders = true) {
switch (method) {
case CRUDMETHOD.GET:
return this.instance.get(this.endpoint + path, (requireHeaders && this.headers));
case CRUDMETHOD.PUT:
return this.instance.put(this.endpoint + path, data, (requireHeaders && this.headers));
case CRUDMETHOD.POST:
return this.instance.post(this.endpoint + path, data, (requireHeaders && this.headers));
case CRUDMETHOD.DELETE:
return this.instance.delete(this.endpoint + path, (requireHeaders && this.headers));
}
}
async getAll() {
const response = await this.instance.get(this.endpoint, this.headers);
return Promise.resolve(response.data);
const response = await this.instance.get(this.endpoint, this.headers)
.catch((err: AxiosError) => {
console.log(err.message);
return null;
});
return Promise.resolve(response?.data);
}
async getByID(id: string) {
const response = await this.instance.get(this.endpoint + "/" + id, this.headers);
return Promise.resolve(response.data);
const response = await this.instance.get(this.endpoint + "/" + id, this.headers)
.catch((err: AxiosError) => {
console.log(err.message);
return null;
});
return Promise.resolve(response?.data);
}
async post(data: T) {
console.log(data);
const response = await this.instance.post(this.endpoint, data, this.headers);
return Promise.resolve(response.data);
try {
const response = await this.instance.post(this.endpoint, JSON.stringify(data), this.headers);
return Promise.resolve(response.data);
} catch(e: any) {
if (e instanceof AxiosError) {
const error = e as AxiosError;
if (error.message) {
console.log(error.message);
return null;
}
}
}
}
async put(id: string, data: T | Partial<T>) {
const response = await this.instance.put(this.endpoint + "/" + id, JSON.stringify(data), this.headers);
return Promise.resolve(response.data);
try {
const response = await this.instance.put(this.endpoint + "/" + id, JSON.stringify(data), this.headers);
return Promise.resolve(response.data);
} catch(e: any) {
if (e instanceof AxiosError) {
const error = e as AxiosError;
if (error.message) {
console.log(error.message);
return null;
}
}
}
}
async delete(id: string) {
const response = await this.instance.delete(this.endpoint + '/' + id, this.headers);
return Promise.resolve(response.data);
try {
const response = await this.instance.delete(this.endpoint + '/' + id, this.headers);
return Promise.resolve(response.data);
} catch(e: any) {
if (e instanceof AxiosError) {
const error = e as AxiosError;
if (error.message) {
console.log(error.message);
return null;
}
}
}
}
}
@@ -118,25 +175,61 @@ module API {
export class Friendship extends RestController<IFriendship> {
constructor(token: string) {
super(Settings.getAPISTRING() + "/app/friends", token);
super(Settings.getAPISTRING() + "/app/friend", token);
}
async getTargetUserFriendships(id: string | number) {
try {
const response = await this.instance.get(this.endpoint + `?targetUser=${id}`, this.headers);
return Promise.resolve(response.data);
} catch (e) {
const error = e as AxiosError;
if (error.response?.status == 404) {
console.log('no friends found');
return [];
}
}
}
async getPendingFriendRequests() {
const response = await this.instance.get(this.endpoint + "?pending=true", this.headers);
return Promise.resolve(response.data);
}
async getActiveFriends() {
const response = await this.instance.get(this.endpoint + "?accepted=true", this.headers);
return Promise.resolve(response.data);
}
async addFriend(id: string | number) {
const response = await this.instance.post(this.endpoint + `/${id}`, this.headers);
return Promise.resolve(response.data);
}
}
export class Recipe extends RestController<IRecipe> {
constructor(token: string) {
super(Settings.getAPISTRING() + "/app/recipes", token);
super(Settings.getAPISTRING() + "/app/recipe", token);
}
async addIngredientToRecipe(ingredient: RecipeIngredient, recipeid: string | number) {
const response = await this.instance.post(this.endpoint + `?addIngredients=true&recipeID=${recipeid}`, JSON.stringify(ingredient), this.headers)
.catch((err) => {
console.log(err);
return null;
});
return Promise.resolve(response?.data);
}
}
export class Ingredient extends RestController<IIngredient> {
constructor(token: string) {
if (!token) throw new Error("Missing required token");
super(Settings.getAPISTRING() + "/app/ingredients", token);
super(Settings.getAPISTRING() + "/app/ingredient", token);
}
async getAllForRecipe(recipeID: string | number) {
const response = await this.instance.get(this.endpoint + `?recipeID=${recipeID}`, this.headers);
return Promise.resolve(response.data);
}
}
@@ -144,6 +237,23 @@ module API {
constructor(token: string) {
super(Settings.getAPISTRING() + "/app/collection", token);
}
async getRecipesFromOne(id?: number | string) {
const response = await this.instance.get(this.endpoint + `/${id}?getRecipes=true`, this.headers);
return Promise.resolve(response.data);
}
async getAllAuthored(id?: number | string) {
let response: AxiosResponse;
if (id) {
response = await this.customRoute(CRUDMETHOD.GET, `?authored=true&authorID=${id}`);
} else {
response = await this.customRoute(CRUDMETHOD.GET, "?authored=true");
}
return Promise.resolve(response.data);
}
}
export class GroceryList extends RestController<IGroceryList> {
@@ -151,6 +261,22 @@ module API {
super(Settings.getAPISTRING() + "/app/grocery-list", token)
}
}
export class Dropdowns extends RestController<DropdownData> {
constructor(token: string) {
super(Settings.getAPISTRING() + "/app/dropdown", token);
}
async getAllMeasurements() {
const response = await this.instance.get(this.endpoint + "?datatype=measurement", this.headers);
return Promise.resolve(response.data);
}
async getAllCourses() {
const response = await this.instance.get(this.endpoint + "?datatype=course", this.headers);
return Promise.resolve(response.data);
}
}
}
export default API

View File

@@ -1,31 +1,45 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import AccessForbidden from "../components/pages/StatusPages/403";
import { Button, Page } from "../components/ui";
import Divider from "../components/ui/Divider";
import { useAuthContext } from "../context/AuthContext";
import API from "./API";
import { ProtectPortal } from "./types";
const Protect: ProtectPortal = ({ children, redirect = '' }) => {
const { user } = useAuthContext();
const Protect: ProtectPortal = ({ children, redirect = '', accessRules = null }) => {
const { user, token } = useAuthContext();
const navigate = useNavigate();
if (!user) {
if (!user || !token) {
return (
<Page>
<div className="content-unauthorized">
<h1>Hi there! You don't look too familiar.</h1>
<p>To view the content on this page, please log in below:</p>
<Divider />
<Button onClick={() => navigate(redirect ? `/login?redirect=${redirect}` : '/login')}>Log In</Button>
</div>
</Page>
)
} else {
return (
<Page>
{ children || <></> }
</Page>
<AccessForbidden>
<>
<h2>Hi there! You don't look too familiar.</h2>
<p>To view the content on this page, please log in below:</p>
<Button onClick={() => navigate(redirect ? `/login?redirect=${redirect}` : '/login')}>Log In</Button>
</>
</AccessForbidden>
)
}
if (accessRules !== null) {
if (accessRules.mustBeRecipinAdmin && !(user?.isadmin)) {
return (
<AccessForbidden>
<>
<h2>This page requires administrator access.</h2>
<p>If you believe you are receiving this message in error, please contact Recipin support.</p>
</>
</AccessForbidden>
)
}
}
return (
<Page>
{ children }
</Page>
)
}
export default Protect;

View File

@@ -14,7 +14,9 @@ instance.interceptors.response.use((res: AxiosResponse<any,any>) => {
return res;
}, (err) => {
return Promise.reject(err);
console.log(err);
// return err;
// return Promise.reject(err);
})
export default instance;

View File

@@ -1,11 +1,12 @@
import { ChangeEvent, ChangeEventHandler, Dispatch, FC, ReactNode, SetStateAction } from "react";
import { useNavigate } from "react-router-dom";
import { Form } from "../components/ui";
import { IUser } from "../schemas";
import { DropdownData, IIngredient, IUser } from "../schemas";
export interface PortalBase {
children?: ReactNode | ReactNode[]
extraStyles?: string
extraClasses?: string
id?: string
}
interface ButtonParams extends PortalBase {
@@ -14,14 +15,19 @@ interface ButtonParams extends PortalBase {
disabledText?: string
}
export interface AccessRules {
mustBeRecipinAdmin: boolean
mustBeFriend: boolean
mustBeSubscribed: boolean
}
export interface ProtectParams extends PortalBase {
redirect?: string
accessRules?: Partial<AccessRules> | null
}
interface UserCardProps extends PortalBase {
user: IUser
canAdd?: boolean
liftData?: (data: any) => void
targetUser: IUser
}
interface NavbarProps {
@@ -40,6 +46,22 @@ interface CheckboxProps {
FormElement: typeof Form
}
export interface IngredientFieldData {
rowPosition: number
quantity: number | undefined
measurement: DropdownData['name'] | undefined
ingredientSelection: IIngredient['name'] | IIngredient | undefined
ingredients: Array<IIngredient>
}
/**
* Type declaration for react-select dropdown options
*/
export interface OptionType {
value: number
label: string
}
export type PageComponent = FC<PortalBase>
export type PanelComponent = FC<PortalBase>
export type ButtonComponent = FC<ButtonParams>

2
dev.sh Normal file
View File

@@ -0,0 +1,2 @@
#! /bin/bash
concurrently "cd server && npm run dev" "cd client && npm run dev"

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "recipe-manager",
"version": "1.0.0",
"description": "A concept developed by Mikayla Dobson",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bash dev.sh"
},
"repository": {
"type": "git",
"url": "git+https://github.com/innocuous-symmetry/recipe-manager.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/innocuous-symmetry/recipe-manager/issues"
},
"homepage": "https://github.com/innocuous-symmetry/recipe-manager#readme",
"devDependencies": {
"concurrently": "^7.6.0"
}
}

View File

@@ -5,7 +5,7 @@ import { IUser } from "../schemas";
dotenv.config();
export function restrictAccess(req: Request, res: Response, next: NextFunction) {
if (req.session.user == undefined) {
if (req.user == undefined) {
res.send("content restricted");
} else {
next();

View File

@@ -15,6 +15,14 @@ export default class CollectionCtl {
return new ControllerResponse(code, data);
}
async getRecipesFromOne(id: number | string) {
const result = await CollectionInstance.getRecipesFromOne(id);
const ok: boolean = result !== null;
const code: StatusCode = ok ? StatusCode.OK : StatusCode.NotFound;
const data: string | ICollection[] = result || "No collection found with this ID";
return new ControllerResponse(code, data);
}
async getAll() {
const result = await CollectionInstance.getAll();
const ok = result !== null;
@@ -23,6 +31,13 @@ export default class CollectionCtl {
return new ControllerResponse(code, data);
}
async getAllAuthored(id: number | string) {
const result = await CollectionInstance.getAllAuthored(id);
const code = (result !== null) ? StatusCode.OK : StatusCode.NotFound;
const data = result || "No collections found";
return new ControllerResponse(code, data);
}
async getUserDefault(id: number | string) {
const result = await CollectionInstance.getUserDefault(id);
const code = (result !== null) ? StatusCode.OK : StatusCode.NotFound;

View File

@@ -0,0 +1,49 @@
import Dropdown from "../models/dropdownValues";
import { DropdownDataType } from "../schemas";
import ControllerResponse from "../util/ControllerResponse";
import { StatusCode } from "../util/types";
const DDInstance = new Dropdown();
export default class DropdownCtl {
async getMeasurements() {
try {
const result = await DDInstance.getMeasurements();
return new ControllerResponse(
((result !== null) ? StatusCode.OK : StatusCode.NotFound),
result || "Measurement unit data not found",
(result !== null)
);
} catch (error: any) {
throw new Error(error);
}
}
async getCourses() {
try {
const result = await DDInstance.getCourses();
return new ControllerResponse(
((result !== null) ? StatusCode.OK : StatusCode.NotFound),
result || "Course data not found",
(result !== null)
);
} catch (error: any) {
throw new Error(error);
}
}
async getByType(type: DropdownDataType) {
switch (type) {
case "measurement":
const result = await DDInstance.getMeasurements();
return new ControllerResponse<any[] | string>(
((result !== null) ? StatusCode.OK : StatusCode.NotFound),
result || "Measurement unit data not found",
(result !== null)
);
case "course":
break;
default:
break;
}
}
}

View File

@@ -16,6 +16,17 @@ export default class IngredientCtl {
}
}
async getAllForRecipe(recipeid: string) {
try {
const result = await IngredientInstance.getAllForRecipe(recipeid);
const ok = result !== null;
const code = ok ? StatusCode.OK : StatusCode.NotFound;
return new ControllerResponse(code, (result || "No ingredient found with this recipe ID"));
} catch (e: any) {
throw new Error(e);
}
}
async getOne(id: string) {
try {
const result = await IngredientInstance.getOne(id);

View File

@@ -1,4 +1,4 @@
import { IRecipe } from "../schemas";
import { IRecipe, RecipeIngredient } from "../schemas";
import { Recipe } from "../models/recipe";
import ControllerResponse from "../util/ControllerResponse";
import { StatusCode } from "../util/types";
@@ -58,4 +58,15 @@ export default class RecipeCtl {
throw new Error(error);
}
}
async addIngredientToRecipe(ingredient: RecipeIngredient, recipeid: string | number) {
try {
const result = await RecipeInstance.addIngredientToRecipe(ingredient, recipeid);
const ok = result !== null;
const code = ok ? StatusCode.NewContent : StatusCode.BadRequest;
return new ControllerResponse(code, (result || "Something went wrong"));
} catch (error: any) {
throw new Error(error);
}
}
}

4
server/controllers/UserCtl.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
/**
* @method getAll
* @returns { ControllerResponse<IUser[] | string> }
*/

View File

@@ -5,6 +5,18 @@ import { StatusCode } from '../util/types';
const UserInstance = new User();
export default class UserCtl {
/* * * * * * * * * * * * * * * * * * * * * * * * * * *
* FIRST SECTION:
* METHODS SPECIFIC TO USERS AND USER DATA
* * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* ### @method getAll
* returns all available user entries
*
* @params (none)
* @returns list of users, or an explanatory string if no response is received
*/
async getAll() {
try {
// attempt to get users from database
@@ -22,6 +34,11 @@ export default class UserCtl {
}
}
/**
* ### @method post
* @param body - serialized user data as { IUser }
* @returns the newly inserted user entry, or an explanatory string
*/
async post(body: IUser) {
try {
const response = await UserInstance.post(body);
@@ -34,6 +51,11 @@ export default class UserCtl {
}
}
/**
* ### @method getOne
* @param id - user id to query
* @returns the user entry, if found, or an explanatory string if none was found
*/
async getOne(id: number | string) {
try {
const user = await UserInstance.getOneByID(id);
@@ -46,6 +68,12 @@ export default class UserCtl {
}
}
/**
* ### @method updateOne
* @param id - user id to update
* @param body - the new user body to update with
* @returns the updated user body, or an explanatory string
*/
async updateOne(id: number | string, body: IUser) {
try {
const result = await UserInstance.updateOneByID(id, body);
@@ -58,6 +86,16 @@ export default class UserCtl {
}
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * *
* SECOND SECTION:
* METHODS SPECIFIC TO FRIENDSHIPS BETWEEN USERS
* * * * * * * * * * * * * * * * * * * * * * * * * * */
/**
* ### @method getFriends
* @param id - get all friendship entries for a user, regardless of status
* @returns a list of friendship entries, or an explanatory string if none are found
*/
async getFriends(id: number | string) {
try {
const result = await UserInstance.getFriends(id);
@@ -70,6 +108,12 @@ export default class UserCtl {
}
}
/**
* ### @method getFriendshipByID
* @param id - the ID of the friendship in question
* @param userid - the user ID of the logged in user, to verify permissions
* @returns a friendship entry, or an explanatory string
*/
async getFriendshipByID(id: number | string, userid: number | string) {
try {
const { ok, code, result } = await UserInstance.getFriendshipByID(id, userid);
@@ -79,6 +123,11 @@ export default class UserCtl {
}
}
/**
* ### @method getPendingFriendRequests
*
* *IMPORTANT*: I don't think this one works the way I think it does
*/
async getPendingFriendRequests(recipient: string | number) {
try {
const { ok, code, result } = await UserInstance.getPendingFriendRequests(recipient);
@@ -88,6 +137,15 @@ export default class UserCtl {
}
}
async getAcceptedFriends(userid: number | string) {
try {
const { code, result } = await UserInstance.getAcceptedFriends(userid);
return new ControllerResponse(code, result);
} catch (e: any) {
throw new Error(e);
}
}
async addFriendship(userid: number | string, targetid: number | string) {
try {
const result = await UserInstance.addFriendship(userid, targetid);

View File

@@ -101,10 +101,31 @@ export default async function populate() {
;
`
const populateMeasurements = `
INSERT INTO recipin.dropdownVals
(name, datatype, datecreated)
VALUES
('cup', 'MEASUREMENTS', $1),
('tablespoon', 'MEASUREMENTS', $1),
('teaspoon', 'MEASUREMENTS', $1),
('gram', 'MEASUREMENTS', $1),
('ounce', 'MEASUREMENTS', $1),
('fluid ounce', 'MEASUREMENTS', $1),
('pound', 'MEASUREMENTS', $1),
('breakfast', 'COURSE', $1),
('lunch', 'COURSE', $1),
('dinner', 'COURSE', $1),
('dessert', 'COURSE', $1),
('appetizer', 'COURSE', $1),
('side', 'COURSE', $1)
;
`
const allStatements: Array<string> = [
populateUsers, populateCuisines, populateCourses,
populateCollection, populateIngredients, populateRecipes,
populateGroceryList, populateFriendships, populateComments
populateGroceryList, populateFriendships, populateComments,
populateMeasurements
];
await pool.query(setup);

View File

@@ -28,11 +28,12 @@ dotenv.config();
const recipecollection = fs.readFileSync(appRoot + '/db/sql/create/createcmp_recipecollection.sql').toString();
const usersubscriptions = fs.readFileSync(appRoot + '/db/sql/create/createcmp_usersubscriptions.sql').toString();
const userfriendships = fs.readFileSync(appRoot + '/db/sql/create/createcmp_userfriendships.sql').toString();
const dropdownValues = fs.readFileSync(appRoot + '/db/sql/create/createdropdown.sql').toString();
const allStatements = [
setRole, appusers, ingredient, collection, cuisine, course,
recipe, recipecomments, groceryList, recipeingredient,
recipecollection, usersubscriptions, userfriendships
recipecollection, usersubscriptions, userfriendships, dropdownValues
]
try {

View File

@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS recipin.dropdownVals (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR NOT NULL,
datatype VARCHAR CHECK(datatype in ('MEASUREMENTS', 'COURSE', 'INGREDIENT')),
datecreated VARCHAR NOT NULL
);

View File

@@ -18,6 +18,39 @@ export class Collection {
}
}
async getRecipesFromOne(id: number | string) {
try {
const statement = `
SELECT * FROM recipin.recipe
INNER JOIN recipin.cmp_recipecollection
ON recipe.id = cmp_recipecollection.recipeid
WHERE cmp_recipecollection.collectionid = $1;
`;
const values = [id];
const result = await pool.query(statement, values);
if (result.rows.length) return result.rows;
return null;
} catch (e: any) {
throw new Error(e);
}
}
async getAllAuthored(id: number | string) {
console.log(id, typeof id);
try {
const statement = `
SELECT * FROM recipin.collection
WHERE ownerid = $1;
`
const result = await pool.query(statement, [id]);
console.log(result.rows);
if (result.rows.length) return result.rows;
return null;
} catch (e: any) {
throw new Error(e);
}
}
async getUserDefault(id: number | string) {
try {
const statement = `
@@ -46,7 +79,6 @@ export class Collection {
}
async post(data: ICollection) {
console.log('new default collection');
const { name, active, ismaincollection, ownerid } = data;
try {
const statement = `

View File

@@ -0,0 +1,25 @@
import pool from "../db";
export default class Dropdown {
async getMeasurements() {
try {
const statement = `SELECT * FROM recipin.dropdownVals WHERE datatype = 'MEASUREMENTS'`;
const result = await pool.query(statement);
if (result.rows.length) return result.rows;
return null;
} catch (error: any) {
throw new Error(error);
}
}
async getCourses() {
try {
const statement = `SELECT * FROM recipin.dropdownVals WHERE datatype = 'COURSE'`;
const result = await pool.query(statement);
if (result.rows.length) return result.rows;
return null;
} catch (error: any) {
throw new Error(error);
}
}
}

View File

@@ -25,6 +25,17 @@ export class Ingredient {
}
}
async getAllForRecipe(recipeid: string) {
try {
const statement = `SELECT * FROM recipin.cmp_recipeingredient WHERE recipeid = $1`;
const result = await pool.query(statement, [recipeid]);
if (result.rows.length) return result.rows[0];
return null;
} catch (e: any) {
throw new Error(e);
}
}
async post(data: IIngredient) {
try {
const statement = `

View File

@@ -1,4 +1,4 @@
import { IRecipe } from "../schemas";
import { IIngredient, IRecipe, RecipeIngredient } from "../schemas";
import fs from 'fs';
import pool from "../db";
import { CollectionCtl } from "../controllers";
@@ -114,4 +114,24 @@ export class Recipe {
throw new Error(error);
}
}
async addIngredientToRecipe(ingredient: RecipeIngredient, recipeid: string | number) {
const { quantity, unit, ingredientid } = ingredient;
try {
const statement = `
INSERT INTO recipin.cmp_recipeingredient
(quantity, unit, ingredientid, recipeid)
VALUES ($1, $2, $3, $4) RETURNING *
`
const result = await pool.query(statement, [quantity, unit, ingredientid, recipeid]);
if (result.rows) return result.rows[0];
return [];
} catch (e: any) {
throw new Error(e);
}
}
}

View File

@@ -140,6 +140,18 @@ export class User {
}
}
async getAcceptedFriends(userid: number | string) {
try {
const statement = `SELECT * FROM recipin.cmp_userfriendships WHERE active = true AND (senderid = $1) OR (targetid = $1);`
const result = await pool.query(statement, [userid]);
if (result.rows.length) return { ok: true, code: StatusCode.OK, result: result.rows }
return { ok: true, code: StatusCode.NotFound, result: "No pending friend requests found" }
} catch (e: any) {
throw new Error(e);
}
}
async addFriendship(userid: number | string, targetid: number | string) {
try {
const statement = `

View File

@@ -16,11 +16,6 @@ const router = Router();
export const authRoute = (app: Express) => {
app.use('/auth', router);
router.use((req, res, next) => {
console.log(req.session);
next();
})
router.get('/', restrictAccess, (req, res, next) => {
if (req.session.user) {
const user = req.session.user;
@@ -44,8 +39,6 @@ export const authRoute = (app: Express) => {
router.post('/login', async (req, res, next) => {
try {
const data: IUserAuth = req.body;
console.log(data);
const response: ControllerResponse<any> = await AuthInstance.login(data);
if (response.ok) {
@@ -70,8 +63,6 @@ export const authRoute = (app: Express) => {
return next(err);
})
console.log(req.session);
res.cookie('token', token, { httpOnly: true });
res.json({ token });
} else {

View File

@@ -1,6 +1,8 @@
import { Express, Router } from "express";
import { checkIsAdmin, restrictAccess } from "../auth/middlewares";
import CollectionCtl from "../controllers/CollectionCtl";
import { IUser } from "../schemas";
import { StatusCode } from "../util/types";
const CollectionInstance = new CollectionCtl();
const router = Router();
@@ -8,35 +10,50 @@ const router = Router();
export const collectionRoute = (app: Express) => {
app.use('/app/collection', router);
router.use((req, res, next) => {
console.log('what gives');
console.log(req.body);
next();
})
router.get('/:id', async (req, res, next) => {
router.get('/:id', restrictAccess, async (req, res, next) => {
const { id } = req.params;
const { getRecipes } = req.query;
try {
const { code, data } = await CollectionInstance.getOne(id);
res.status(code).send(data);
if (getRecipes || getRecipes == "true") {
const { code, data } = await CollectionInstance.getRecipesFromOne(id);
res.status(code).send(data);
} else {
const { code, data } = await CollectionInstance.getOne(id);
res.status(code).send(data);
}
} catch(e) {
next(e);
}
})
// implement is admin on this route
router.get('/', checkIsAdmin, async (req, res, next) => {
router.get('/', restrictAccess, async (req, res, next) => {
const user = req.user as IUser;
const { authored, authorID } = req.query;
try {
const { code, data } = await CollectionInstance.getAll();
res.status(code).send(data);
if (authorID) {
const { code, data } = await CollectionInstance.getAllAuthored(parseInt(authorID as string));
res.status(code).send(data);
} else if (authored || authored == "true") {
const { code, data } = await CollectionInstance.getAllAuthored(user.id as number);
res.status(code).send(data);
} else {
if (user.isadmin) {
const { code, data } = await CollectionInstance.getAll();
res.status(code).send(data);
} else {
res.status(403).send("Unauthorized");
}
}
} catch(e) {
next(e);
}
})
router.post('/', async (req, res, next) => {
router.post('/', restrictAccess, async (req, res, next) => {
const data = req.body;
console.log(req.body ?? "sanity check");
try {
const result = await CollectionInstance.post(data);

View File

@@ -0,0 +1,32 @@
import { Express, Router } from 'express';
import DropdownCtl from '../controllers/DropdownCtl';
import { DropdownDataType } from '../schemas';
const router = Router();
const DDInstance = new DropdownCtl();
export const dropdownValueRouter = (app: Express) => {
app.use('/app/dropdown', router);
router.get('/', async (req, res, next) => {
const { datatype } = req.query;
try {
switch (datatype) {
case "measurement":
const measurements = await DDInstance.getMeasurements();
res.status(measurements.code).send(measurements.data);
break;
case "course":
const courses = await DDInstance.getCourses();
res.status(courses.code).send(courses.data);
break;
default: break;
}
} catch (error) {
next(error);
}
})
return router;
}

View File

@@ -2,6 +2,7 @@ import { Express, Router } from 'express';
import { restrictAccess } from '../auth/middlewares';
import { UserCtl } from '../controllers';
import { IUser } from '../schemas';
import { StatusCode } from '../util/types';
const UserInstance = new UserCtl();
const router = Router();
@@ -9,19 +10,8 @@ const router = Router();
export const friendRouter = (app: Express) => {
app.use('/app/friend', router);
router.use((req, res, next) => {
let test = req.session.user;
if (req.session.user == undefined) {
throw new Error("No session found");
} else {
const narrowed = req.session.user;
next();
}
})
router.post('/:targetid', restrictAccess, async (req, res, next) => {
const user = req.session.user as IUser;
const user = req.user as IUser;
const { targetid } = req.params;
try {
@@ -34,17 +24,28 @@ export const friendRouter = (app: Express) => {
// get all friendships for a user
router.get('/', async (req, res, next) => {
const user = req.session.user as IUser;
const { pending } = req.query;
const user = req.user as IUser;
const { pending, accepted, targetUser } = req.query;
try {
if (pending) {
const { code, data } = await UserInstance.getPendingFriendRequests(user.id as number);
res.status(code).send(data);
} else {
const { code, data } = await UserInstance.getFriends(user.id as number);
} else if (accepted) {
const { code, data } = await UserInstance.getAcceptedFriends(user.id as number);
res.status(code).send(data);
} else {
if (targetUser) {
const { code, data } = await UserInstance.getFriends(parseInt(targetUser as string));
res.status(code).send(data);
} else {
const { code, data } = await UserInstance.getFriends(user.id as number);
res.status(code).send(data);
}
}
// send server error in case any of these conditions not landing
res.status(StatusCode.ServerError).json({ message: "An unexpected error occurred." });
} catch(e) {
next(e);
}
@@ -53,7 +54,7 @@ export const friendRouter = (app: Express) => {
// get one friendship by its id
router.get('/:id', async (req, res, next) => {
const { id } = req.params;
const user = req.session.user as IUser;
const user = req.user as IUser;
try {
const { code, data } = await UserInstance.getFriendshipByID(id, user.id as number);
@@ -76,7 +77,7 @@ export const friendRouter = (app: Express) => {
router.put('/:id', async (req, res, next) => {
const data = req.body;
const { id } = req.params;
const user = req.session.user as IUser;
const user = req.user as IUser;
try {
const response = await UserInstance.updateFriendship(id, user.id as number, data);
@@ -85,4 +86,6 @@ export const friendRouter = (app: Express) => {
next(e);
}
})
return router;
}

View File

@@ -11,35 +11,52 @@ import { subscriptionRoute } from "./subscription";
import { friendRouter } from "./friend";
import { cuisineRouter } from "./cuisine";
import { courseRouter } from "./course";
import { dropdownValueRouter } from "./dropdownValues";
import { IUser } from "../schemas";
import { User } from "../models/user";
dotenv.config();
let REQUESTCOUNT = 0;
export const routes = async (app: Express) => {
// unprotected routes
authRoute(app);
// simple request counting middleware
app.use('/', (req, res, next) => {
REQUESTCOUNT++;
console.log("count: ", REQUESTCOUNT);
next();
})
// middleware to check for auth on cookies on each request in protected routes
app.use('/app', async (req, res, next) => {
// pull jwt from request headers
console.log(req.headers);
const token = req.headers['authorization']?.split(" ")[1];
console.log(token);
if (!token) {
res.status(403).send("Unauthorized, did not receive token");
} else {
jwt.verify(token, process.env.SESSIONSECRET as string, (err, data) => {
jwt.verify(token, process.env.SESSIONSECRET as string, async (err, data: any) => {
if (err) {
res.status(403).send(err);
} else {
console.log(data);
req.user = data;
const userInstance = new User();
const foundUser = await userInstance.getOneByID(data.user.id);
if (foundUser) {
req.user = data.user as IUser;
} else {
res.status(403).send("Unauthorized, user not registered");
}
next();
}
})
}
})
// unprotected routes
authRoute(app);
// protected routes
userRoute(app);
friendRouter(app);
@@ -49,6 +66,7 @@ export const routes = async (app: Express) => {
subscriptionRoute(app);
groceryListRoute(app);
courseRouter(app);
dropdownValueRouter(app);
// deprecate?
cuisineRouter(app);

View File

@@ -10,9 +10,16 @@ export const ingredientRoute = (app: Express) => {
app.use('/app/ingredient', router);
router.get('/', async (req, res, next) => {
const { recipeID } = req.query;
try {
const result: CtlResponse<IIngredient[] | string> = await IngredientInstance.getAll();
res.status(result.code).send(result.data);
if (recipeID) {
const result = await IngredientInstance.getAllForRecipe(recipeID as string);
res.status(result.code).send(result.data);
} else {
const result: CtlResponse<IIngredient[] | string> = await IngredientInstance.getAll();
res.status(result.code).send(result.data);
}
} catch(e) {
next(e);
}
@@ -51,4 +58,6 @@ export const ingredientRoute = (app: Express) => {
next(e);
}
})
return router;
}

View File

@@ -22,12 +22,12 @@ export const recipeRoute = (app: Express) => {
})
router.get('/', restrictAccess, async (req, res, next) => {
const user = req.session.user as IUser;
const { filterby } = req.query;
const user = req.user as IUser;
const { filter } = req.query;
try {
let result: CtlResponse<IRecipe[] | string>;
switch (filterby) {
switch (filter) {
case "myrecipes":
result = await recipectl.getAllAuthored(user.id as number);
break;
@@ -55,12 +55,20 @@ export const recipeRoute = (app: Express) => {
})
router.post('/', restrictAccess, async (req, res, next) => {
const user = req.session.user as IUser;
const user = req.user as IUser;
const data = req.body;
const { addIngredients, recipeID } = req.query;
console.log(data);
try {
const result = await recipectl.post(user.id as number, data);
res.status(result.code).send(result.data);
if (addIngredients) {
const result = await recipectl.addIngredientToRecipe(data, recipeID as string);
res.status(result.code).send(result.data);
} else {
const result = await recipectl.post(user.id as number, data);
res.status(result.code).send(result.data);
}
} catch(e) {
next(e);
}

View File

@@ -1,6 +1,7 @@
import { Express, Router } from "express"
import { restrictAccess } from "../auth/middlewares";
import { CollectionCtl } from "../controllers";
import { IUser } from "../schemas";
const CollectionInstance = new CollectionCtl();
const router = Router();
@@ -8,12 +9,11 @@ export const subscriptionRoute = (app: Express) => {
app.use('/app/subscription', router);
router.get('/', async (req, res, next) => {
// @ts-ignore
const { user } = req.session.user;
const user = req.user as IUser;
if (!user) return;
try {
const result = await CollectionInstance.getSubscriptions(user.id as string);
const result = await CollectionInstance.getSubscriptions(user.id!);
res.status(200).send(result);
} catch(e) {
next(e);
@@ -21,12 +21,11 @@ export const subscriptionRoute = (app: Express) => {
})
router.post('/', restrictAccess, async (req, res, next) => {
// @ts-ignore
const { user } = req.session.user;
const user = req.user as IUser;
const { collection } = req.query;
try {
const result = await CollectionInstance.postSubscription(collection as string, user.id as string);
const result = await CollectionInstance.postSubscription(collection as string, user.id!);
res.status(201).send(result);
} catch(e) {
next(e);

View File

@@ -44,6 +44,13 @@ export interface IIngredient extends HasHistory {
createdbyid: string | number
}
export interface RecipeIngredient extends Partial<IIngredient> {
unit: string
quantity: string | number
ingredientid: string | number
recipeid: string | number
}
export interface ICollection extends HasHistory, CanDeactivate {
name: string
ismaincollection: boolean
@@ -73,3 +80,10 @@ export interface FlavorProfile extends HasHistory, CanDeactivate {
name: string
description?: string
}
export interface DropdownData extends HasHistory {
name: string
datatype: DropdownDataType
}
export type DropdownDataType = "measurement" | "course"