Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
698 changes: 153 additions & 545 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@
"classnames": "^2.2.6",
"formik": "^2.1.4",
"headroom.js": "^0.11.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "^6.1.0",
"react-redux": "^7.2.0",
"react-router-dom": "^5.1.2",
"react-toast-notifications": "2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-hot-toast": "^2.4.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.1.5",
"reactstrap": "^8.4.1",
"redux": "^4.0.5"
"redux": "^5.0.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
Expand Down
14 changes: 14 additions & 0 deletions src/_services/list.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const listService = {
unfav,
addItem,
deleteItem,
searchExternal,
};

async function getAll(filter) {
Expand Down Expand Up @@ -112,4 +113,17 @@ async function deleteItem(idToken, list_id, item_id) {
const res = await fetch(import.meta.env.VITE_API_URL + "lists/" + list_id + "/items/" + item_id + "/delete", requestOptions).then(handleErrors);
const result = await res.json();
return result.item;
}

async function searchExternal(idToken, query) {
let url = new URL(import.meta.env.VITE_API_URL + "search/");
url.search = new URLSearchParams({ q: query }).toString();
const requestOptions = {
headers: {
'Authorization': 'Bearer ' + idToken,
},
};
const res = await fetch(url, requestOptions).then(handleErrors);
const result = await res.json();
return result.results;
}
2 changes: 1 addition & 1 deletion src/components/home/Home.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import { Helmet } from 'react-helmet-async';

import checklistImg from 'assets/img/theme/checklist.svg';
import reviewImg from 'assets/img/theme/review.svg';
Expand Down
4 changes: 3 additions & 1 deletion src/components/list/CreateList.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { useNavigate } from 'react-router-dom';

// reactstrap components
import {
Expand All @@ -22,6 +23,7 @@ import App from 'App';
import { listService } from '_services/list.service';

function CreateList(props) {
const navigate = useNavigate();

useEffect(() => {
document.documentElement.scrollTop = 0;
Expand All @@ -31,7 +33,7 @@ function CreateList(props) {
const createList = (event) => {
event.preventDefault();
const data = new FormData(event.target);
listService.create(props.user.idToken, data).then(list => props.history.push("/list/" + list.id));
listService.create(props.user.idToken, data).then(list => navigate("/list/" + list.id));
};


Expand Down
2 changes: 1 addition & 1 deletion src/components/list/Explore.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import classnames from 'classnames';
import { Helmet } from 'react-helmet';
import { Helmet } from 'react-helmet-async';

import checklistImg from 'assets/img/theme/checklist.svg';

Expand Down
47 changes: 22 additions & 25 deletions src/components/list/ListPage.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { Formik, Form, Field } from 'formik';
import { useToasts } from 'react-toast-notifications';
import { Helmet } from 'react-helmet';
import toast from 'react-hot-toast';
import { Helmet } from 'react-helmet-async';

// reactstrap components
import {
Expand All @@ -21,12 +20,12 @@ import {
// core components
import App from 'App';
import Item from 'components/list/_item';
import ItemSearchField from 'components/list/_itemSearchField';

import { listService } from '_services/list.service';

function ListDetails(props) {
const [list, setList] = useState(props.list);
const { addToast } = useToasts()

const addItem = (item) => {
let newList = Object.assign({}, list);
Expand All @@ -45,28 +44,28 @@ function ListDetails(props) {
listService.addItem(props.user.idToken, props.list.id, values).then((item) => {
resetForm();
addItem(item);
addToast("Item added succesfully", { appearance: 'info', autoDismiss: true })
toast.success("Item added succesfully")
}).catch(error => {
addToast(error.message, { appearance: 'error', autoDismiss: true })
toast.error(error.message)
});
};

const deleteItem = (itemId) => {
listService.deleteItem(props.user.idToken, props.list.id, itemId).then(item => {
replaceItem(item);
addToast("Item deleted succesfully", { appearance: 'info', autoDismiss: true })
toast.success("Item deleted succesfully")
}).catch(error => {
addToast(error.message, { appearance: 'error', autoDismiss: true })
toast.error(error.message)
});
}

const favList = (event) => {
listService.fav(props.user.idToken, props.list.id).then(list => {
props.user.favs.push(list.id);
setList(list);
addToast("List Favorited", { appearance: 'info', autoDismiss: true })
toast.success("List Favorited")
}).catch(error => {
addToast(error.message, { appearance: 'error', autoDismiss: true })
toast.error(error.message)
});
};

Expand All @@ -75,9 +74,9 @@ function ListDetails(props) {
const index = props.user.favs.indexOf(props.list.id)
if (index !== -1) props.user.favs.splice(index);
setList(list);
addToast("List unfaved", { appearance: 'info', autoDismiss: true })
toast.success("List unfaved")
}).catch(error => {
addToast(error.message, { appearance: 'error', autoDismiss: true })
toast.error(error.message)
});
};

Expand All @@ -89,18 +88,17 @@ function ListDetails(props) {
<Card className="mb-4 shadow">
<CardBody>
<Formik
initialValues={{ name: '', url: '', description: '' }}
initialValues={{ name: '', url: '', description: '', pic_url: '' }}
onSubmit={handleAddItem}>
{(props) => (
<Form>
<Row>
<Col md="5">
<FormGroup>
<Field
className="form-control form-control-alternative"
name="name"
placeholder="Item name"
type="text" />
<ItemSearchField
idToken={user.idToken}
value={props.values.name}
setFieldValue={props.setFieldValue} />
</FormGroup>
</Col>
<Col md="5">
Expand Down Expand Up @@ -218,19 +216,18 @@ function ListDetails(props) {
)
}

const ListDetailsWithRouter = withRouter(ListDetails);

function ListPage(props) {
const { id } = useParams();
const [listRequest, setListRequest] = useState({
list: [],
loading: true,
error: undefined
});

useEffect(() => {
document.documentElement.scrollTop = 0;
document.scrollingElement.scrollTop = 0;
listService.get(props.match.params.id)
listService.get(id)
.then(
(list) => {
setListRequest({
Expand All @@ -247,7 +244,7 @@ function ListPage(props) {
});
}
)
}, [props.match.params.id]);
}, [id]);

const {list, error, loading} = listRequest
return (
Expand Down Expand Up @@ -276,7 +273,7 @@ function ListPage(props) {
<meta property="og:description" content={list.description} />
<meta property="og:image" content={list.owner.avatar_url ? list.owner.avatar_url : "https://joeschmoe.io/api/v1/" + list.owner.email} />
</Helmet>
<ListDetailsWithRouter key={list.id} list={list} user={props.user} />
<ListDetails key={list.id} list={list} user={props.user} />
</>
}
</Container>
Expand Down
121 changes: 121 additions & 0 deletions src/components/list/_itemSearchField.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import React, { useState, useRef, useEffect } from 'react';

import { listService } from '_services/list.service';

const CATEGORY_LABELS = {
book: 'Books',
movie: 'Movies & TV',
music: 'Music',
};
const CATEGORY_ORDER = ['book', 'movie', 'music'];

// ItemSearchField is the "Item name" input, upgraded to a search-as-you-type box
// that queries external catalogs (books / movies & TV / music). Selecting a
// result autofills the Formik `name`, `url` and `pic_url` fields. Typing freely
// (without selecting) still works — the backend then falls back to og:image.
function ItemSearchField({ idToken, value, setFieldValue }) {
const [results, setResults] = useState([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const debounceRef = useRef(null);
const containerRef = useRef(null);

// Close the dropdown when clicking outside.
useEffect(() => {
const onClick = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, []);

const runSearch = (query) => {
if (query.trim().length < 2) {
setResults([]);
setOpen(false);
return;
}
setLoading(true);
listService.searchExternal(idToken, query)
.then((res) => {
setResults(res || []);
setOpen(true);
})
.catch(() => {
setResults([]);
})
.finally(() => setLoading(false));
};

const onChange = (e) => {
const next = e.target.value;
setFieldValue('name', next);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(next), 300);
};

const onSelect = (result) => {
setFieldValue('name', result.name);
setFieldValue('url', result.url || '');
setFieldValue('pic_url', result.pic_url || '');
setOpen(false);
setResults([]);
};

const grouped = CATEGORY_ORDER
.map((cat) => ({ cat, items: results.filter((r) => r.category === cat) }))
.filter((g) => g.items.length > 0);

return (
<div ref={containerRef} style={{ position: 'relative' }}>
<input
className="form-control form-control-alternative"
name="name"
placeholder="Search a book, movie or album… or type a name"
type="text"
autoComplete="off"
value={value}
onChange={onChange}
onFocus={() => { if (results.length > 0) setOpen(true); }}
/>
{open && (loading || grouped.length > 0) && (
<div
className="dropdown-menu show shadow w-100"
style={{ maxHeight: '320px', overflowY: 'auto', position: 'absolute', zIndex: 1000 }}>
{loading && grouped.length === 0 && (
<span className="dropdown-item-text text-muted">Searching…</span>
)}
{grouped.map((group) => (
<React.Fragment key={group.cat}>
<h6 className="dropdown-header">{CATEGORY_LABELS[group.cat]}</h6>
{group.items.map((result, idx) => (
<button
key={group.cat + idx}
type="button"
className="dropdown-item d-flex align-items-center"
onClick={() => onSelect(result)}
style={{ whiteSpace: 'normal' }}>
{result.pic_url
? <img
src={result.pic_url}
alt=""
style={{ width: '32px', height: '48px', objectFit: 'cover', marginRight: '12px', flexShrink: 0 }} />
: <span style={{ width: '32px', height: '48px', marginRight: '12px', flexShrink: 0 }} />}
<span>
<strong className="d-block">{result.name}</strong>
{result.description &&
<small className="text-muted">{result.description}</small>}
</span>
</button>
))}
</React.Fragment>
))}
</div>
)}
</div>
);
}

export default ItemSearchField;
11 changes: 6 additions & 5 deletions src/components/navbar/CustomNavbar.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Link, withRouter } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { connect } from 'react-redux';

import { setUser } from 'redux/actions';
Expand Down Expand Up @@ -33,7 +33,8 @@ import { loadSession, clearSession } from '_helpers/auth';

function CustomNavbar(props) {
const [collapseClasses, setCollapseClasses] = useState("");
var {user, history} = props;
var { user } = props;
const navigate = useNavigate();

useEffect(() => {
async function fetchData() {
Expand All @@ -44,11 +45,11 @@ function CustomNavbar(props) {
if (saved) {
props.setUser(saved);
if (saved.username === "") {
history.push("/create");
navigate("/create");
}
}
} else if (user.username === "") {
history.push("/create");
navigate("/create");
}
}
fetchData();
Expand Down Expand Up @@ -205,4 +206,4 @@ const mapStateToProps = state => {
export default connect(
mapStateToProps,
{ setUser }
)(withRouter(CustomNavbar));
)(CustomNavbar);
Loading