mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 23:15:36 +00:00
feat: add favorite link btn
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import axios from "axios";
|
||||
import LinkTag from "next/link";
|
||||
import { AiFillStar } from "react-icons/ai";
|
||||
|
||||
@@ -10,8 +11,26 @@ import LinkFavicon from "./LinkFavicon";
|
||||
|
||||
import styles from "./links.module.scss";
|
||||
|
||||
export default function LinkItem({ link }: { link: Link }) {
|
||||
export default function LinkItem({
|
||||
link,
|
||||
toggleFavorite,
|
||||
}: {
|
||||
link: Link;
|
||||
toggleFavorite: (linkId: Link["id"]) => void;
|
||||
}) {
|
||||
const { id, name, url, favorite } = link;
|
||||
const onFavorite = () => {
|
||||
const payload = {
|
||||
name,
|
||||
url,
|
||||
favorite: !favorite,
|
||||
categoryId: link.category.id,
|
||||
};
|
||||
axios
|
||||
.put(`/api/link/edit/${link.id}`, payload)
|
||||
.then(() => toggleFavorite(link.id))
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<li className={styles["link"]} key={id}>
|
||||
@@ -23,7 +42,7 @@ export default function LinkItem({ link }: { link: Link }) {
|
||||
<LinkItemURL url={url} />
|
||||
</LinkTag>
|
||||
<div className={styles["controls"]}>
|
||||
<FavoriteItem isFavorite={favorite} />
|
||||
<FavoriteItem isFavorite={favorite} onClick={onFavorite} />
|
||||
<EditItem type="link" id={id} />
|
||||
<RemoveItem type="link" id={id} />
|
||||
</div>
|
||||
@@ -31,7 +50,7 @@ export default function LinkItem({ link }: { link: Link }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LinkItemURL({ url }: { url: string }) {
|
||||
function LinkItemURL({ url }: { url: Link["url"] }) {
|
||||
try {
|
||||
const { origin, pathname, search } = new URL(url);
|
||||
let text = "";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import LinkTag from "next/link";
|
||||
|
||||
import { Category } from "types";
|
||||
import { Category, Link } from "types";
|
||||
|
||||
import EditItem from "components/QuickActions/EditItem";
|
||||
import RemoveItem from "components/QuickActions/RemoveItem";
|
||||
@@ -8,7 +8,13 @@ import LinkItem from "./LinkItem";
|
||||
|
||||
import styles from "./links.module.scss";
|
||||
|
||||
export default function Links({ category }: { category: Category }) {
|
||||
export default function Links({
|
||||
category,
|
||||
toggleFavorite,
|
||||
}: {
|
||||
category: Category;
|
||||
toggleFavorite: (linkId: Link["id"]) => void;
|
||||
}) {
|
||||
if (category === null) {
|
||||
return (
|
||||
<div className={styles["no-category"]}>
|
||||
@@ -44,7 +50,7 @@ export default function Links({ category }: { category: Category }) {
|
||||
</h2>
|
||||
<ul className={styles["links"]} key={Math.random()}>
|
||||
{links.map((link, key) => (
|
||||
<LinkItem key={key} link={link} />
|
||||
<LinkItem key={key} link={link} toggleFavorite={toggleFavorite} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useState } from "react";
|
||||
import { ReactNode } from "react";
|
||||
import { AiFillStar, AiOutlineStar } from "react-icons/ai";
|
||||
|
||||
export default function FavoriteItem({
|
||||
@@ -8,19 +8,12 @@ export default function FavoriteItem({
|
||||
}: {
|
||||
isFavorite: boolean;
|
||||
children?: ReactNode;
|
||||
onClick?: (event: any) => void; // FIXME: find good event type
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const starColor = "#ffc107";
|
||||
const [isItemFavorite, setFavorite] = useState<boolean>(isFavorite);
|
||||
|
||||
const handleClick = (event) => {
|
||||
onClick && onClick(event);
|
||||
setFavorite((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div onClick={handleClick}>
|
||||
{isItemFavorite ? (
|
||||
<div onClick={onClick}>
|
||||
{isFavorite ? (
|
||||
<AiFillStar color={starColor} />
|
||||
) : (
|
||||
<AiOutlineStar color={starColor} />
|
||||
|
||||
@@ -8,7 +8,7 @@ import LinkFavicon from "components/Links/LinkFavicon";
|
||||
import Modal from "components/Modal/Modal";
|
||||
import TextBox from "components/TextBox";
|
||||
|
||||
import { Category, ItemComplete, Link } from "types";
|
||||
import { Category, Link, SearchItem } from "types";
|
||||
|
||||
import styles from "./search.module.scss";
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function SearchModal({
|
||||
handleSelectCategory: (category: Category) => void;
|
||||
categories: Category[];
|
||||
favorites: Link[];
|
||||
items: ItemComplete[];
|
||||
items: SearchItem[];
|
||||
}) {
|
||||
const autoFocusRef = useAutoFocus();
|
||||
|
||||
@@ -130,7 +130,7 @@ function ListItemComponent({
|
||||
items,
|
||||
noItem,
|
||||
}: {
|
||||
items: ItemComplete[];
|
||||
items: SearchItem[];
|
||||
noItem?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -156,7 +156,7 @@ function ListItemComponent({
|
||||
);
|
||||
}
|
||||
|
||||
function ItemComponent({ item }: { item: ItemComplete }) {
|
||||
function ItemComponent({ item }: { item: SearchItem }) {
|
||||
const { name, type, url } = item;
|
||||
return (
|
||||
<li className={styles["item"]}>
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function Favorites({ favorites }: { favorites: Link[] }) {
|
||||
<div className={styles["favorites"]}>
|
||||
<h4>Favoris</h4>
|
||||
<ul className={styles["items"]}>
|
||||
{favorites.map((link, key) => (
|
||||
<FavoriteItem link={link} key={key} />
|
||||
{favorites.map((link) => (
|
||||
<FavoriteItem link={link} key={link.id} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
import useModal from "hooks/useModal";
|
||||
@@ -8,9 +8,10 @@ import Links from "components/Links/Links";
|
||||
import SearchModal from "components/SearchModal/SearchModal";
|
||||
import SideMenu from "components/SideMenu/SideMenu";
|
||||
|
||||
import { Category, ItemComplete, Link } from "types";
|
||||
import { Category, Link, SearchItem } from "types";
|
||||
import { prisma } from "utils/back";
|
||||
import { BuildCategory } from "utils/front";
|
||||
import { pushStateVanilla } from "utils/link";
|
||||
|
||||
const OPEN_SEARCH_KEY = "s";
|
||||
const CLOSE_SEARCH_KEY = "escape";
|
||||
@@ -20,58 +21,114 @@ const OPEN_CREATE_CATEGORY_KEY = "c";
|
||||
|
||||
interface HomeProps {
|
||||
categories: Category[];
|
||||
favorites: Link[];
|
||||
items: ItemComplete[];
|
||||
currentCategory: Category | undefined;
|
||||
}
|
||||
|
||||
function Home({ categories, favorites, currentCategory, items }: HomeProps) {
|
||||
function Home(props: HomeProps) {
|
||||
const router = useRouter();
|
||||
const modal = useModal();
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>(props.categories);
|
||||
const [categoryActive, setCategoryActive] = useState<Category | null>(
|
||||
currentCategory || categories?.[0]
|
||||
props.currentCategory || categories?.[0]
|
||||
);
|
||||
|
||||
const favorites = useMemo<Link[]>(
|
||||
() =>
|
||||
categories.reduce((acc, category) => {
|
||||
category.links.forEach((link) =>
|
||||
link.favorite ? acc.push(link) : null
|
||||
);
|
||||
return acc;
|
||||
}, [] as Link[]),
|
||||
[categories]
|
||||
);
|
||||
|
||||
const searchItemBuilder = (
|
||||
item: Category | Link,
|
||||
type: SearchItem["type"]
|
||||
): SearchItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: type === "link" ? (item as Link).url : `/?categoryId=${item.id}`,
|
||||
type,
|
||||
});
|
||||
|
||||
const itemsSearch = useMemo<SearchItem[]>(() => {
|
||||
const items = categories.reduce((acc, category) => {
|
||||
const categoryItem = searchItemBuilder(category, "category");
|
||||
const items: SearchItem[] = category.links.map((link) =>
|
||||
searchItemBuilder(link, "link")
|
||||
);
|
||||
return [...acc, ...items, categoryItem];
|
||||
}, [] as SearchItem[]);
|
||||
|
||||
return items;
|
||||
}, [categories]);
|
||||
|
||||
// TODO: refacto
|
||||
const toggleFavorite = useCallback(
|
||||
(linkId: Link["id"]) => {
|
||||
let linkIndex = 0;
|
||||
const categoryIndex = categories.findIndex(({ links }) => {
|
||||
const lIndex = links.findIndex((l) => l.id === linkId);
|
||||
if (lIndex !== -1) {
|
||||
linkIndex = lIndex;
|
||||
}
|
||||
return lIndex !== -1;
|
||||
});
|
||||
|
||||
const link = categories[categoryIndex].links[linkIndex];
|
||||
const categoriesCopy = [...categories];
|
||||
categoriesCopy[categoryIndex].links[linkIndex] = {
|
||||
...link,
|
||||
favorite: !link.favorite,
|
||||
};
|
||||
|
||||
setCategories(categoriesCopy);
|
||||
if (categories[categoryIndex].id === categoryActive.id) {
|
||||
setCategoryActive(categories[categoryIndex]);
|
||||
}
|
||||
},
|
||||
[categories, categoryActive.id]
|
||||
);
|
||||
|
||||
const handleSelectCategory = (category: Category) => {
|
||||
setCategoryActive(category);
|
||||
const newUrl = `/?categoryId=${category.id}`;
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
"",
|
||||
newUrl
|
||||
);
|
||||
pushStateVanilla(`/?categoryId=${category.id}`);
|
||||
};
|
||||
|
||||
const openSearchModal = useCallback(
|
||||
useHotkeys(
|
||||
OPEN_SEARCH_KEY,
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
modal.open();
|
||||
},
|
||||
[modal]
|
||||
{ enabled: !modal.isShowing }
|
||||
);
|
||||
const closeSearchModal = useCallback(() => modal.close(), [modal]);
|
||||
|
||||
const gotoCreateLink = useCallback(() => {
|
||||
router.push(`/link/create?categoryId=${categoryActive.id}`);
|
||||
}, [categoryActive.id, router]);
|
||||
|
||||
const gotoCreateCategory = useCallback(() => {
|
||||
router.push("/category/create");
|
||||
}, [router]);
|
||||
|
||||
useHotkeys(OPEN_SEARCH_KEY, openSearchModal, { enabled: !modal.isShowing });
|
||||
useHotkeys(CLOSE_SEARCH_KEY, closeSearchModal, {
|
||||
useHotkeys(CLOSE_SEARCH_KEY, modal.close, {
|
||||
enabled: modal.isShowing,
|
||||
enableOnFormTags: ["INPUT"],
|
||||
});
|
||||
|
||||
useHotkeys(OPEN_CREATE_LINK_KEY, gotoCreateLink, {
|
||||
enabled: !modal.isShowing,
|
||||
});
|
||||
useHotkeys(OPEN_CREATE_CATEGORY_KEY, gotoCreateCategory, {
|
||||
enabled: !modal.isShowing,
|
||||
});
|
||||
useHotkeys(
|
||||
OPEN_CREATE_LINK_KEY,
|
||||
() => {
|
||||
router.push(`/link/create?categoryId=${categoryActive.id}`);
|
||||
},
|
||||
{
|
||||
enabled: !modal.isShowing,
|
||||
}
|
||||
);
|
||||
useHotkeys(
|
||||
OPEN_CREATE_CATEGORY_KEY,
|
||||
() => {
|
||||
router.push("/category/create");
|
||||
},
|
||||
{
|
||||
enabled: !modal.isShowing,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
@@ -82,13 +139,13 @@ function Home({ categories, favorites, currentCategory, items }: HomeProps) {
|
||||
categoryActive={categoryActive}
|
||||
openSearchModal={modal.open}
|
||||
/>
|
||||
<Links category={categoryActive} />
|
||||
<Links category={categoryActive} toggleFavorite={toggleFavorite} />
|
||||
{modal.isShowing && (
|
||||
<SearchModal
|
||||
close={modal.close}
|
||||
categories={categories}
|
||||
favorites={favorites}
|
||||
items={items}
|
||||
items={itemsSearch}
|
||||
handleSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
)}
|
||||
@@ -98,40 +155,11 @@ function Home({ categories, favorites, currentCategory, items }: HomeProps) {
|
||||
|
||||
export async function getServerSideProps({ query }) {
|
||||
const queryCategoryId = (query?.categoryId as string) || "";
|
||||
|
||||
const categoriesDB = await prisma.category.findMany({
|
||||
include: { links: true },
|
||||
});
|
||||
|
||||
const items = [] as ItemComplete[];
|
||||
|
||||
const favorites = [] as Link[];
|
||||
const categories = categoriesDB.map((categoryDB) => {
|
||||
const category = BuildCategory(categoryDB);
|
||||
|
||||
category.links.map((link) => {
|
||||
if (link.favorite) {
|
||||
favorites.push(link);
|
||||
}
|
||||
items.push({
|
||||
id: link.id,
|
||||
name: link.name,
|
||||
url: link.url,
|
||||
type: "link",
|
||||
});
|
||||
});
|
||||
|
||||
items.push({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
url: `/?categoryId=${category.id}`,
|
||||
type: "category",
|
||||
});
|
||||
|
||||
return category;
|
||||
});
|
||||
|
||||
if (categories.length === 0) {
|
||||
if (categoriesDB.length === 0) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/category/create",
|
||||
@@ -139,15 +167,14 @@ export async function getServerSideProps({ query }) {
|
||||
};
|
||||
}
|
||||
|
||||
const categories = categoriesDB.map(BuildCategory);
|
||||
const currentCategory = categories.find(
|
||||
(c) => c.id === Number(queryCategoryId)
|
||||
({ id }) => id === Number(queryCategoryId)
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
categories: JSON.parse(JSON.stringify(categories)),
|
||||
favorites: JSON.parse(JSON.stringify(favorites)),
|
||||
items: JSON.parse(JSON.stringify(items)),
|
||||
currentCategory: currentCategory
|
||||
? JSON.parse(JSON.stringify(currentCategory))
|
||||
: null,
|
||||
|
||||
2
src/types.d.ts
vendored
2
src/types.d.ts
vendored
@@ -27,7 +27,7 @@ export interface Link {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ItemComplete {
|
||||
export interface SearchItem {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export function faviconLinkBuilder(origin: string) {
|
||||
return `http://localhost:3000/api/favicon?url=${origin}`;
|
||||
}
|
||||
|
||||
export function pushStateVanilla(newUrl: string) {
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
"",
|
||||
newUrl
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user