feat: add create link form

This commit is contained in:
Sonny
2024-05-09 23:14:42 +02:00
committed by Sonny
parent 2cf8c5ae02
commit 73f8c0c513
16 changed files with 244 additions and 64 deletions

View File

@@ -1,26 +1,26 @@
import PATHS from '#constants/paths';
import Collection from '#models/collection';
import User from '#models/user';
import { collectionValidator } from '#validators/collection';
import type { HttpContext } from '@adonisjs/core/http';
export default class CollectionsController {
async index({ auth, inertia }: HttpContext) {
const collections = await Collection.findManyBy('author_id', auth.user!.id);
// Dashboard
async index({ auth, inertia, response }: HttpContext) {
const collections = await this.getCollectionByAuthorId(auth.user!.id);
if (collections.length === 0) {
return response.redirect('/collections/create');
}
const collectionsWithLinks = await Promise.all(
collections.map(async (collection) => {
await collection.load('links');
return collection;
})
);
return inertia.render('dashboard', { collections: collectionsWithLinks });
return inertia.render('dashboard', { collections });
}
// Create collection form
async showCreatePage({ inertia }: HttpContext) {
return inertia.render('collections/create');
}
// Method called when creating a collection
async store({ request, response, auth }: HttpContext) {
const payload = await request.validateUsing(collectionValidator);
const collection = await Collection.create({
@@ -30,7 +30,17 @@ export default class CollectionsController {
return this.redirectToCollectionId(response, collection.id);
}
private redirectToCollectionId(
async getCollectionById(id: Collection['id']) {
return await Collection.find(id);
}
async getCollectionByAuthorId(authorId: User['id']) {
return await Collection.query()
.where('author_id', authorId)
.preload('links');
}
redirectToCollectionId(
response: HttpContext['response'],
collectionId: Collection['id']
) {

View File

@@ -0,0 +1,32 @@
import CollectionsController from '#controllers/collections_controller';
import Link from '#models/link';
import { linkValidator } from '#validators/link';
import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http';
@inject()
export default class LinksController {
constructor(protected collectionsController: CollectionsController) {}
async showCreatePage({ auth, inertia }: HttpContext) {
const collections =
await this.collectionsController.getCollectionByAuthorId(auth.user!.id);
return inertia.render('links/create', { collections });
}
async store({ auth, request, response }: HttpContext) {
const { collectionId, ...payload } =
await request.validateUsing(linkValidator);
await this.collectionsController.getCollectionById(collectionId);
await Link.create({
...payload,
collectionId,
authorId: auth.user?.id!,
});
return this.collectionsController.redirectToCollectionId(
response,
collectionId
);
}
}

View File

@@ -1,8 +1,8 @@
import AppBaseModel from '#models/app_base_model';
import Link from '#models/link';
import User from '#models/user';
import { belongsTo, column, manyToMany } from '@adonisjs/lucid/orm';
import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations';
import { belongsTo, column, hasMany } from '@adonisjs/lucid/orm';
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations';
import { Visibility } from '../enums/visibility.js';
export default class Collection extends AppBaseModel {
@@ -24,6 +24,6 @@ export default class Collection extends AppBaseModel {
@belongsTo(() => User, { foreignKey: 'authorId' })
declare author: BelongsTo<typeof User>;
@manyToMany(() => Link)
declare links: ManyToMany<typeof Link>;
@hasMany(() => Link)
declare links: HasMany<typeof Link>;
}

11
app/validators/link.ts Normal file
View File

@@ -0,0 +1,11 @@
import vine from '@vinejs/vine';
export const linkValidator = vine.compile(
vine.object({
name: vine.string().trim().minLength(1).maxLength(254),
description: vine.string().trim().maxLength(300).optional(),
url: vine.string().trim(),
favorite: vine.boolean(),
collectionId: vine.string().trim(),
})
);

View File

@@ -1,24 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema';
export default class extends BaseSchema {
protected tableName = 'collection_link';
async up() {
this.schema.createTable(this.tableName, (table) => {
table
.uuid('collection_id')
.references('id')
.inTable('collections')
.onDelete('CASCADE');
table
.uuid('link_id')
.references('id')
.inTable('links')
.onDelete('CASCADE');
});
}
async down() {
this.schema.dropTable(this.tableName);
}
}

View File

@@ -1,7 +1,10 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { TbLoader3 } from 'react-icons/tb';
import { TfiWorld } from 'react-icons/tfi';
import { rotate } from '~/styles/keyframes';
const IMG_LOAD_TIMEOUT = 7_500;
interface LinkFaviconProps {
url: string;
@@ -22,7 +25,7 @@ const FaviconLoader = styled.div(({ theme }) => ({
backgroundColor: theme.colors.white,
'& > *': {
animation: 'rotate 1s both reverse infinite linear',
animation: `${rotate} 1s both reverse infinite linear`,
},
}));
@@ -35,25 +38,27 @@ export default function LinkFavicon({
}: LinkFaviconProps) {
const [isFailed, setFailed] = useState<boolean>(false);
const [isLoading, setLoading] = useState<boolean>(true);
const baseUrlApi =
(process.env.NEXT_PUBLIC_SITE_URL ||
(typeof window !== 'undefined' && window?.location?.origin)) + '/api';
if (!baseUrlApi) {
console.warn('Missing API URL');
}
const setFallbackFavicon = () => setFailed(true);
const handleStopLoading = () => setLoading(false);
const handleErrorLoading = () => {
setFallbackFavicon();
handleStopLoading();
};
useEffect(() => {
if (!isLoading) return;
const id = setTimeout(() => handleErrorLoading(), IMG_LOAD_TIMEOUT);
return () => clearTimeout(id);
}, [isLoading]);
return (
<Favicon style={{ marginRight: !noMargin ? '1em' : '0' }}>
{!isFailed && baseUrlApi ? (
{!isFailed ? (
<img
src={`${baseUrlApi}/favicon?urlParam=${url}`}
onError={() => {
setFallbackFavicon();
handleStopLoading();
}}
src={`/favicon?urlParam=${url}`}
onError={handleErrorLoading}
onLoad={handleStopLoading}
height={size}
width={size}

View File

@@ -1,4 +1,4 @@
import Collection from '#models/collection';
import type Collection from '#models/collection';
export const appendCollectionId = (
url: string,
@@ -7,3 +7,15 @@ export const appendCollectionId = (
export const appendResourceId = (url: string, resourceId?: string) =>
`${url}${resourceId && `/${resourceId}`}}`;
export function isValidHttpUrl(urlParam: string) {
let url;
try {
url = new URL(urlParam);
} catch (_) {
return false;
}
return url.protocol === 'http:' || url.protocol === 'https:';
}

View File

@@ -7,7 +7,7 @@ import FormLayout from '~/components/layouts/form_layout';
import { Visibility } from '../../../app/enums/visibility';
export default function CreateCollectionPage() {
const { data, setData, post, processing, errors } = useForm({
const { data, setData, post, processing } = useForm({
name: '',
description: '',
visibility: Visibility.PRIVATE,
@@ -45,7 +45,6 @@ export default function CreateCollectionPage() {
required
autoFocus
/>
{errors.name && <div>{errors.name}</div>}
<TextBox
label="Collection description"
placeholder="Collection description"
@@ -53,10 +52,14 @@ export default function CreateCollectionPage() {
onChange={setData}
value={data.name}
/>
{errors.description && <div>{errors.description}</div>}
<FormField>
<label htmlFor="visibility">Public</label>
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
<input
type="checkbox"
onChange={handleOnCheck}
value={data.visibility}
id="visibility"
/>
</FormField>
</BackToDashboard>
</FormLayout>

View File

@@ -0,0 +1,97 @@
import type Collection from '#models/collection';
import { useForm } from '@inertiajs/react';
import { ChangeEvent, FormEvent, useMemo } from 'react';
import FormField from '~/components/common/form/_form_field';
import TextBox from '~/components/common/form/textbox';
import BackToDashboard from '~/components/common/navigation/bask_to_dashboard';
import FormLayout from '~/components/layouts/form_layout';
import useSearchParam from '~/hooks/use_search_param';
import { isValidHttpUrl } from '~/lib/navigation';
export default function CreateLinkPage({
collections,
}: {
collections: Collection[];
}) {
const collectionId = useSearchParam('collectionId') ?? collections[0].id;
const { data, setData, post, processing } = useForm({
name: '',
description: '',
url: '',
favorite: false,
collectionId: collectionId,
});
const canSubmit = useMemo<boolean>(
() =>
data.name !== '' &&
isValidHttpUrl(data.url) &&
data.favorite !== null &&
data.collectionId !== null &&
!processing,
[data, processing]
);
const handleOnCheck = ({ target }: ChangeEvent<HTMLInputElement>) => {
setData('favorite', !!target.checked);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
post('/links');
};
return (
<FormLayout
title="Create a link"
handleSubmit={handleSubmit}
canSubmit={canSubmit}
>
<BackToDashboard>
<TextBox
label="Link name"
placeholder="Link name"
name="name"
onChange={setData}
value={data.name}
required
autoFocus
/>
<TextBox
label="Link url"
placeholder="Link url"
name="url"
onChange={setData}
value={data.url}
required
/>
<TextBox
label="Link description"
placeholder="Link description"
name="description"
onChange={setData}
value={data.description}
/>
<select
onChange={({ target }) => setData('collectionId', target.value)}
defaultValue={data.collectionId}
>
{collections.map((collection) => (
<option key={collection.id} value={collection.id}>
{collection.name}
</option>
))}
</select>
<FormField required>
<label htmlFor="favorite">Favorite</label>
<input
type="checkbox"
onChange={handleOnCheck}
checked={data.favorite}
id="favorite"
/>
</FormField>
</BackToDashboard>
</FormLayout>
);
}

View File

@@ -8,3 +8,12 @@ export const fadeIn = keyframes({
opacity: 1,
},
});
export const rotate = keyframes({
to: {
transform: 'rotate(0deg)',
},
from: {
transform: 'rotate(360deg)',
},
});

View File

@@ -46,4 +46,25 @@ export const cssReset = css({
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset',
display: 'inline-block',
},
/* width */
'::-webkit-scrollbar': {
height: '0.45em',
width: '0.45em',
},
/* Track */
'::-webkit-scrollbar-track': {
borderRadius: theme.border.radius,
},
/* Handle */
'::-webkit-scrollbar-thumb': {
background: theme.colors.blue,
borderRadius: theme.border.radius,
'&:hover': {
background: theme.colors.darkBlue,
},
},
});

View File

@@ -58,7 +58,7 @@ export const theme: Theme = {
},
border: {
radius: '5px',
radius: '3px',
},
media: {

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "my-links",
"version": "0.0.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-links",
"version": "0.0.0",
"version": "1.3.0",
"license": "UNLICENSED",
"dependencies": {
"@adonisjs/ally": "^5.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "my-links",
"version": "0.0.0",
"version": "1.3.0",
"private": true,
"type": "module",
"license": "UNLICENSED",

View File

@@ -7,8 +7,8 @@
<title inertia>MyLinks</title>
@viteReactRefresh()
@inertiaHead()
@viteReactRefresh()
@vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])
</head>

View File

@@ -2,6 +2,7 @@ import PATHS from '#constants/paths';
import { middleware } from '#start/kernel';
import router from '@adonisjs/core/services/router';
const LinksController = () => import('#controllers/links_controller');
const CollectionsController = () =>
import('#controllers/collections_controller');
const UsersController = () => import('#controllers/users_controller');
@@ -22,5 +23,8 @@ router
'showCreatePage',
]);
router.post('/collections', [CollectionsController, 'store']);
router.get(PATHS.LINK.CREATE, [LinksController, 'showCreatePage']);
router.post('/links', [LinksController, 'store']);
})
.middleware([middleware.auth()]);