4 Commits
1.0.0 ... 1.1.0

Author SHA1 Message Date
Sonny
883b36c93e chore: release v1.1.0 2024-04-09 23:17:30 +02:00
Sonny
584489dbb9 feat: add optionnal link description 2024-04-09 23:05:14 +02:00
Sonny
cf6b87306e feat: add optionnal "required" param on TextBox and Selector inputs 2024-04-09 23:00:43 +02:00
Sonny
5a792aef13 ci: update startup script command 2024-03-29 19:27:39 +01:00
19 changed files with 127 additions and 68 deletions

View File

@@ -50,4 +50,4 @@ jobs:
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
script: | script: |
cd /infra/my-links cd /infra/my-links
docker compose up -d sh startup.sh

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "my-links", "name": "my-links",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-links", "name": "my-links",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@ducanh2912/next-pwa": "^10.0.0", "@ducanh2912/next-pwa": "^10.0.0",
"@prisma/client": "^5.7.0", "@prisma/client": "^5.7.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "my-links", "name": "my-links",
"version": "1.0.0", "version": "1.1.0",
"description": "MyLinks is a free and open source software, that lets you manage your bookmarks in an intuitive interface", "description": "MyLinks is a free and open source software, that lets you manage your bookmarks in an intuitive interface",
"private": false, "private": false,
"scripts": { "scripts": {

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `link` ADD COLUMN `description` VARCHAR(255) NULL;

View File

@@ -45,9 +45,10 @@ model Category {
} }
model Link { model Link {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @db.VarChar(255) name String @db.VarChar(255)
url String @db.Text description String? @db.VarChar(255)
url String @db.Text
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
categoryId Int categoryId Int

View File

@@ -8,6 +8,7 @@
"links": "Links", "links": "Links",
"link": "Link", "link": "Link",
"name": "Link name", "name": "Link name",
"description": "Link description",
"create": "Create a link", "create": "Create a link",
"edit": "Edit a link", "edit": "Edit a link",
"remove": "Delete a link", "remove": "Delete a link",

View File

@@ -8,6 +8,7 @@
"links": "Liens", "links": "Liens",
"link": "Lien", "link": "Lien",
"name": "Nom du lien", "name": "Nom du lien",
"description": "Description du lien",
"create": "Créer un lien", "create": "Créer un lien",
"edit": "Modifier un lien", "edit": "Modifier un lien",
"remove": "Supprimer un lien", "remove": "Supprimer un lien",

View File

@@ -1,3 +1,4 @@
import ExternalLink from 'components/ExternalLink';
import EditItem from 'components/QuickActions/EditItem'; import EditItem from 'components/QuickActions/EditItem';
import FavoriteItem from 'components/QuickActions/FavoriteItem'; import FavoriteItem from 'components/QuickActions/FavoriteItem';
import RemoveItem from 'components/QuickActions/RemoveItem'; import RemoveItem from 'components/QuickActions/RemoveItem';
@@ -5,7 +6,6 @@ import PATHS from 'constants/paths';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import useCategories from 'hooks/useCategories'; import useCategories from 'hooks/useCategories';
import { makeRequest } from 'lib/request'; import { makeRequest } from 'lib/request';
import LinkTag from 'next/link';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { AiFillStar } from 'react-icons/ai'; import { AiFillStar } from 'react-icons/ai';
import { LinkWithCategory } from 'types'; import { LinkWithCategory } from 'types';
@@ -19,7 +19,7 @@ export default function LinkItem({
link: LinkWithCategory; link: LinkWithCategory;
index: number; index: number;
}) { }) {
const { id, name, url, favorite } = link; const { id, name, url, description, favorite } = link;
const { categories, setCategories } = useCategories(); const { categories, setCategories } = useCategories();
const toggleFavorite = useCallback( const toggleFavorite = useCallback(
@@ -73,32 +73,35 @@ export default function LinkItem({
delay: index * 0.05, delay: index * 0.05,
}} }}
> >
<LinkFavicon url={url} /> <div className={styles['link-header']}>
<LinkTag <LinkFavicon url={url} />
href={url} <ExternalLink
target={'_blank'} href={url}
rel='noreferrer' className='reset'
className='reset' >
> <span className={styles['link-name']}>
<span className={styles['link-name']}> {name} {favorite && <AiFillStar color='#ffc107' />}
{name} {favorite && <AiFillStar color='#ffc107' />} </span>
</span> <LinkItemURL url={url} />
<LinkItemURL url={url} /> </ExternalLink>
</LinkTag> <div className={styles['controls']}>
<div className={styles['controls']}> <FavoriteItem
<FavoriteItem isFavorite={favorite}
isFavorite={favorite} onClick={onFavorite}
onClick={onFavorite} />
/> <EditItem
<EditItem type='link'
type='link' id={id}
id={id} />
/> <RemoveItem
<RemoveItem type='link'
type='link' id={id}
id={id} />
/> </div>
</div> </div>
{description && (
<div className={styles['link-description']}>{description}</div>
)}
</motion.li> </motion.li>
); );
} }

View File

@@ -91,8 +91,18 @@
border: 1px solid $lightest-grey; border: 1px solid $lightest-grey;
border-radius: 3px; border-radius: 3px;
outline: 3px solid transparent; outline: 3px solid transparent;
display: flex;
align-items: center; & .link-header {
display: flex;
align-items: center;
}
& .link-description {
margin-top: 0.5em;
color: $black;
font-size: 0.8em;
word-wrap: break-word;
}
&:hover { &:hover {
border: 1px solid transparent; border: 1px solid transparent;
@@ -109,7 +119,7 @@
} }
} }
.link > a { .link-header > a {
height: 100%; height: 100%;
max-width: calc(100% - 125px); // TODO: faut fix ça, c'est pas beau max-width: calc(100% - 125px); // TODO: faut fix ça, c'est pas beau
text-decoration: none; text-decoration: none;

View File

@@ -23,6 +23,7 @@ interface SelectorProps {
) => ReactNode; ) => ReactNode;
disabled?: boolean; disabled?: boolean;
required?: boolean;
} }
export default function Selector({ export default function Selector({
@@ -36,6 +37,7 @@ export default function Selector({
onChangeCallback, onChangeCallback,
formatOptionLabel, formatOptionLabel,
disabled = false, disabled = false,
required = false,
}: SelectorProps): JSX.Element { }: SelectorProps): JSX.Element {
const [selectorValue, setSelectorValue] = useState<Option>(); const [selectorValue, setSelectorValue] = useState<Option>();
@@ -56,7 +58,7 @@ export default function Selector({
} }
return ( return (
<div className={`input-field ${fieldClass}`}> <div className={`input-field ${fieldClass} ${required && 'required'}`}>
{label && ( {label && (
<label <label
htmlFor={name} htmlFor={name}

View File

@@ -10,6 +10,7 @@ interface InputProps {
inputClass?: string; inputClass?: string;
value?: string; value?: string;
onChangeCallback?: (value) => void; onChangeCallback?: (value) => void;
required?: boolean;
} }
export default function TextBox({ export default function TextBox({
@@ -22,6 +23,7 @@ export default function TextBox({
inputClass = '', inputClass = '',
value, value,
onChangeCallback, onChangeCallback,
required = false,
}: InputProps): JSX.Element { }: InputProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>(value); const [inputValue, setInputValue] = useState<string>(value);
@@ -33,11 +35,11 @@ export default function TextBox({
} }
return ( return (
<div className={`input-field ${fieldClass}`}> <div className={`input-field ${fieldClass} ${required && 'required'}`}>
{label && ( {label && (
<label <label
htmlFor={name} htmlFor={name}
title={`${name} field`} title={label}
> >
{label} {label}
</label> </label>

View File

@@ -6,6 +6,7 @@ const LinkBodySchema = object({
.trim() .trim()
.required('Link name is required') .required('Link name is required')
.max(128, 'Link name is too long'), .max(128, 'Link name is too long'),
description: string().trim().max(255, 'Link description is too long'),
url: string() url: string()
.trim() .trim()
.required('URl is required') .required('URl is required')

View File

@@ -10,9 +10,8 @@ export default apiHandler({
async function editLink({ req, res, user }) { async function editLink({ req, res, user }) {
const { lid } = await LinkQuerySchema.validate(req.query); const { lid } = await LinkQuerySchema.validate(req.query);
const { name, url, favorite, categoryId } = await LinkBodySchema.validate( const { name, url, description, favorite, categoryId } =
req.body, await LinkBodySchema.validate(req.body);
);
const link = await getUserLink(user, lid); const link = await getUserLink(user, lid);
if (!link) { if (!link) {
@@ -22,6 +21,7 @@ async function editLink({ req, res, user }) {
if ( if (
link.name === name && link.name === name &&
link.url === url && link.url === url &&
link.description === description &&
link.favorite === favorite && link.favorite === favorite &&
link.categoryId === categoryId link.categoryId === categoryId
) { ) {
@@ -33,6 +33,7 @@ async function editLink({ req, res, user }) {
data: { data: {
name, name,
url, url,
description,
favorite, favorite,
categoryId, categoryId,
}, },

View File

@@ -10,9 +10,8 @@ export default apiHandler({
}); });
async function createLink({ req, res, user }) { async function createLink({ req, res, user }) {
const { name, url, favorite, categoryId } = await LinkBodySchema.validate( const { name, url, description, favorite, categoryId } =
req.body, await LinkBodySchema.validate(req.body);
);
const link = await getUserLinkByName(user, name, categoryId); const link = await getUserLinkByName(user, name, categoryId);
if (link) { if (link) {
@@ -28,6 +27,7 @@ async function createLink({ req, res, user }) {
data: { data: {
name, name,
url, url,
description,
categoryId, categoryId,
favorite, favorite,
authorId: user.id, authorId: user.id,

View File

@@ -28,6 +28,8 @@ export default function PageCreateLink({
const [name, setName] = useState<LinkWithCategory['name']>(''); const [name, setName] = useState<LinkWithCategory['name']>('');
const [url, setUrl] = useState<LinkWithCategory['url']>(''); const [url, setUrl] = useState<LinkWithCategory['url']>('');
const [description, setDescription] =
useState<LinkWithCategory['description']>('');
const [favorite, setFavorite] = useState<LinkWithCategory['favorite']>(false); const [favorite, setFavorite] = useState<LinkWithCategory['favorite']>(false);
const [categoryId, setCategoryId] = useState< const [categoryId, setCategoryId] = useState<
LinkWithCategory['category']['id'] LinkWithCategory['category']['id']
@@ -54,7 +56,7 @@ export default function PageCreateLink({
makeRequest({ makeRequest({
url: PATHS.API.LINK, url: PATHS.API.LINK,
method: 'POST', method: 'POST',
body: { name, url, favorite, categoryId }, body: { name, url, description, favorite, categoryId },
}) })
.then((data) => .then((data) =>
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`), router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`),
@@ -75,19 +77,29 @@ export default function PageCreateLink({
<TextBox <TextBox
name='name' name='name'
label={t('common:link.name')} label={t('common:link.name')}
onChangeCallback={(value) => setName(value)} onChangeCallback={setName}
value={name} value={name}
fieldClass={styles['input-field']} fieldClass={styles['input-field']}
placeholder={t('common:link.name')} placeholder={t('common:link.name')}
innerRef={autoFocusRef} innerRef={autoFocusRef}
required
/> />
<TextBox <TextBox
name='url' name='url'
label={t('common:link.link')} label={t('common:link.link')}
onChangeCallback={(value) => setUrl(value)} onChangeCallback={setUrl}
value={url} value={url}
fieldClass={styles['input-field']} fieldClass={styles['input-field']}
placeholder='https://www.example.com/' placeholder='https://www.example.com/'
required
/>
<TextBox
name='description'
label={t('common:link.description')}
onChangeCallback={setDescription}
value={description}
fieldClass={styles['input-field']}
placeholder={t('common:link.description')}
/> />
<Selector <Selector
name='category' name='category'
@@ -98,11 +110,12 @@ export default function PageCreateLink({
label: name, label: name,
value: id, value: id,
}))} }))}
required
/> />
<Checkbox <Checkbox
name='favorite' name='favorite'
isChecked={favorite} isChecked={favorite}
onChangeCallback={(value) => setFavorite(value)} onChangeCallback={setFavorite}
label={t('common:favorite')} label={t('common:favorite')}
/> />
</FormLayout> </FormLayout>

View File

@@ -30,6 +30,7 @@ export default function PageEditLink({
const [name, setName] = useState<string>(link.name); const [name, setName] = useState<string>(link.name);
const [url, setUrl] = useState<string>(link.url); const [url, setUrl] = useState<string>(link.url);
const [description, setDescription] = useState<string>(link.description);
const [favorite, setFavorite] = useState<boolean>(link.favorite); const [favorite, setFavorite] = useState<boolean>(link.favorite);
const [categoryId, setCategoryId] = useState<number | null>( const [categoryId, setCategoryId] = useState<number | null>(
link.category?.id || null, link.category?.id || null,
@@ -42,6 +43,7 @@ export default function PageEditLink({
const isFormEdited = const isFormEdited =
name !== link.name || name !== link.name ||
url !== link.url || url !== link.url ||
description !== link.description ||
favorite !== link.favorite || favorite !== link.favorite ||
categoryId !== link.category.id; categoryId !== link.category.id;
const isFormValid = const isFormValid =
@@ -50,17 +52,7 @@ export default function PageEditLink({
favorite !== null && favorite !== null &&
categoryId !== null; categoryId !== null;
return isFormEdited && isFormValid && !submitted; return isFormEdited && isFormValid && !submitted;
}, [ }, [categoryId, description, favorite, link, name, submitted, url]);
categoryId,
favorite,
link.category.id,
link.favorite,
link.name,
link.url,
name,
submitted,
url,
]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => { const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -70,7 +62,7 @@ export default function PageEditLink({
makeRequest({ makeRequest({
url: `${PATHS.API.LINK}/${link.id}`, url: `${PATHS.API.LINK}/${link.id}`,
method: 'PUT', method: 'PUT',
body: { name, url, favorite, categoryId }, body: { name, url, description, favorite, categoryId },
}) })
.then((data) => .then((data) =>
router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`), router.push(`${PATHS.HOME}?categoryId=${data?.categoryId}`),
@@ -90,19 +82,29 @@ export default function PageEditLink({
<TextBox <TextBox
name='name' name='name'
label={t('common:link.name')} label={t('common:link.name')}
onChangeCallback={(value) => setName(value)} onChangeCallback={setName}
value={name} value={name}
fieldClass={styles['input-field']} fieldClass={styles['input-field']}
placeholder={`${t('common:link.name')} : ${link.name}`} placeholder={`${t('common:link.name')} : ${link.name}`}
innerRef={autoFocusRef} innerRef={autoFocusRef}
required
/> />
<TextBox <TextBox
name='url' name='url'
label={t('common:link.link')} label={t('common:link.link')}
onChangeCallback={(value) => setUrl(value)} onChangeCallback={setUrl}
value={url} value={url}
fieldClass={styles['input-field']} fieldClass={styles['input-field']}
placeholder='https://example.com/' placeholder='https://example.com/'
required
/>
<TextBox
name='description'
label={t('common:link.description')}
onChangeCallback={setDescription}
value={description}
fieldClass={styles['input-field']}
placeholder={`${t('common:link.description')} : ${link.description}`}
/> />
<Selector <Selector
name='category' name='category'
@@ -113,11 +115,12 @@ export default function PageEditLink({
label: name, label: name,
value: id, value: id,
}))} }))}
required
/> />
<Checkbox <Checkbox
name='favorite' name='favorite'
isChecked={favorite} isChecked={favorite}
onChangeCallback={(value) => setFavorite(value)} onChangeCallback={setFavorite}
label={t('common:favorite')} label={t('common:favorite')}
/> />
</FormLayout> </FormLayout>

View File

@@ -69,6 +69,13 @@ export default function PageRemoveLink({
fieldClass={styles['input-field']} fieldClass={styles['input-field']}
disabled={true} disabled={true}
/> />
<TextBox
name='description'
label={t('common:link.description')}
value={link.description}
fieldClass={styles['input-field']}
disabled={true}
/>
<TextBox <TextBox
name='category' name='category'
label={t('common:category.category')} label={t('common:category.category')}

View File

@@ -160,11 +160,23 @@ input::placeholder {
} }
.input-field { .input-field {
& label,
& input, & input,
& select { & select {
width: 100%; width: 100%;
} }
& label {
position: relative;
width: fit-content;
}
&.required label::after {
content: '*';
position: absolute;
top: 0;
right: -0.75em;
color: $red;
}
} }
.checkbox-field { .checkbox-field {

View File

@@ -1,11 +1,11 @@
import { Category, User } from '@prisma/client'; import { Category, Link, User } from '@prisma/client';
import { Profile } from 'next-auth'; import { Profile } from 'next-auth';
export type CategoryWithLinks = Category & { export type CategoryWithLinks = Category & {
author: User; author: User;
links: LinkWithCategory[]; links: LinkWithCategory[];
}; };
export type LinkWithCategory = LinkWithCategory & { export type LinkWithCategory = Link & {
author: User; author: User;
category: CategoryWithLinks; category: CategoryWithLinks;
}; };