mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 14:43:24 +00:00
feat: add create link form
This commit is contained in:
@@ -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']
|
||||
) {
|
||||
|
||||
32
app/controllers/links_controller.ts
Normal file
32
app/controllers/links_controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
11
app/validators/link.ts
Normal 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(),
|
||||
})
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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:';
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
97
inertia/pages/links/create.tsx
Normal file
97
inertia/pages/links/create.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,3 +8,12 @@ export const fadeIn = keyframes({
|
||||
opacity: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const rotate = keyframes({
|
||||
to: {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
from: {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export const theme: Theme = {
|
||||
},
|
||||
|
||||
border: {
|
||||
radius: '5px',
|
||||
radius: '3px',
|
||||
},
|
||||
|
||||
media: {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-links",
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
<title inertia>MyLinks</title>
|
||||
|
||||
@viteReactRefresh()
|
||||
@inertiaHead()
|
||||
@viteReactRefresh()
|
||||
@vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])
|
||||
</head>
|
||||
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
Reference in New Issue
Block a user