From 73f8c0c513f773e299a607387600bf98d304ef8d Mon Sep 17 00:00:00 2001 From: Sonny Date: Thu, 9 May 2024 23:14:42 +0200 Subject: [PATCH] feat: add create link form --- app/controllers/collections_controller.ts | 32 +++--- app/controllers/links_controller.ts | 32 ++++++ app/models/collection.ts | 8 +- app/validators/link.ts | 11 +++ ...326455367_create_collection_links_table.ts | 24 ----- .../dashboard/link/link_favicon.tsx | 33 ++++--- inertia/lib/{navigation.tsx => navigation.ts} | 14 ++- inertia/pages/collections/create.tsx | 11 ++- inertia/pages/links/create.tsx | 97 +++++++++++++++++++ inertia/styles/keyframes.ts | 9 ++ inertia/styles/reset.ts | 21 ++++ inertia/styles/theme.ts | 2 +- package-lock.json | 4 +- package.json | 4 +- resources/views/inertia_layout.edge | 2 +- start/routes.ts | 4 + 16 files changed, 244 insertions(+), 64 deletions(-) create mode 100644 app/controllers/links_controller.ts create mode 100644 app/validators/link.ts delete mode 100644 database/migrations/1714326455367_create_collection_links_table.ts rename inertia/lib/{navigation.tsx => navigation.ts} (51%) create mode 100644 inertia/pages/links/create.tsx diff --git a/app/controllers/collections_controller.ts b/app/controllers/collections_controller.ts index 2dbb191..5c67f20 100644 --- a/app/controllers/collections_controller.ts +++ b/app/controllers/collections_controller.ts @@ -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'] ) { diff --git a/app/controllers/links_controller.ts b/app/controllers/links_controller.ts new file mode 100644 index 0000000..7a95aba --- /dev/null +++ b/app/controllers/links_controller.ts @@ -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 + ); + } +} diff --git a/app/models/collection.ts b/app/models/collection.ts index 2e61377..87df0b4 100644 --- a/app/models/collection.ts +++ b/app/models/collection.ts @@ -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; - @manyToMany(() => Link) - declare links: ManyToMany; + @hasMany(() => Link) + declare links: HasMany; } diff --git a/app/validators/link.ts b/app/validators/link.ts new file mode 100644 index 0000000..d2558cb --- /dev/null +++ b/app/validators/link.ts @@ -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(), + }) +); diff --git a/database/migrations/1714326455367_create_collection_links_table.ts b/database/migrations/1714326455367_create_collection_links_table.ts deleted file mode 100644 index 93a0ed6..0000000 --- a/database/migrations/1714326455367_create_collection_links_table.ts +++ /dev/null @@ -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); - } -} diff --git a/inertia/components/dashboard/link/link_favicon.tsx b/inertia/components/dashboard/link/link_favicon.tsx index 5a6e53c..50f786c 100644 --- a/inertia/components/dashboard/link/link_favicon.tsx +++ b/inertia/components/dashboard/link/link_favicon.tsx @@ -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(false); const [isLoading, setLoading] = useState(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 ( - {!isFailed && baseUrlApi ? ( + {!isFailed ? ( { - setFallbackFavicon(); - handleStopLoading(); - }} + src={`/favicon?urlParam=${url}`} + onError={handleErrorLoading} onLoad={handleStopLoading} height={size} width={size} diff --git a/inertia/lib/navigation.tsx b/inertia/lib/navigation.ts similarity index 51% rename from inertia/lib/navigation.tsx rename to inertia/lib/navigation.ts index dcc278f..41602ae 100644 --- a/inertia/lib/navigation.tsx +++ b/inertia/lib/navigation.ts @@ -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:'; +} diff --git a/inertia/pages/collections/create.tsx b/inertia/pages/collections/create.tsx index ef14508..4366a3c 100644 --- a/inertia/pages/collections/create.tsx +++ b/inertia/pages/collections/create.tsx @@ -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 &&
{errors.name}
} - {errors.description &&
{errors.description}
} - + diff --git a/inertia/pages/links/create.tsx b/inertia/pages/links/create.tsx new file mode 100644 index 0000000..cd5a2a0 --- /dev/null +++ b/inertia/pages/links/create.tsx @@ -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( + () => + data.name !== '' && + isValidHttpUrl(data.url) && + data.favorite !== null && + data.collectionId !== null && + !processing, + [data, processing] + ); + + const handleOnCheck = ({ target }: ChangeEvent) => { + setData('favorite', !!target.checked); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + post('/links'); + }; + + return ( + + + + + + + + + + + + + ); +} diff --git a/inertia/styles/keyframes.ts b/inertia/styles/keyframes.ts index b5aa313..64f68e2 100644 --- a/inertia/styles/keyframes.ts +++ b/inertia/styles/keyframes.ts @@ -8,3 +8,12 @@ export const fadeIn = keyframes({ opacity: 1, }, }); + +export const rotate = keyframes({ + to: { + transform: 'rotate(0deg)', + }, + from: { + transform: 'rotate(360deg)', + }, +}); diff --git a/inertia/styles/reset.ts b/inertia/styles/reset.ts index befc70e..cc614cf 100644 --- a/inertia/styles/reset.ts +++ b/inertia/styles/reset.ts @@ -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, + }, + }, }); diff --git a/inertia/styles/theme.ts b/inertia/styles/theme.ts index 3653793..5716fee 100644 --- a/inertia/styles/theme.ts +++ b/inertia/styles/theme.ts @@ -58,7 +58,7 @@ export const theme: Theme = { }, border: { - radius: '5px', + radius: '3px', }, media: { diff --git a/package-lock.json b/package-lock.json index 7b391b6..1e5230d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ee89a1d..961c564 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-links", - "version": "0.0.0", + "version": "1.3.0", "private": true, "type": "module", "license": "UNLICENSED", @@ -111,4 +111,4 @@ "lint-staged": { "*.js,*.ts,*.jsx,*.tsx": "eslint --cache --fix" } -} +} \ No newline at end of file diff --git a/resources/views/inertia_layout.edge b/resources/views/inertia_layout.edge index 4c18f5d..edeb396 100644 --- a/resources/views/inertia_layout.edge +++ b/resources/views/inertia_layout.edge @@ -7,8 +7,8 @@ MyLinks - @viteReactRefresh() @inertiaHead() + @viteReactRefresh() @vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`]) diff --git a/start/routes.ts b/start/routes.ts index 9e4c458..2f14ad1 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -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()]);