mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-09 15:05:35 +00:00
feat: add create link form
This commit is contained in:
@@ -1,26 +1,26 @@
|
|||||||
import PATHS from '#constants/paths';
|
import PATHS from '#constants/paths';
|
||||||
import Collection from '#models/collection';
|
import Collection from '#models/collection';
|
||||||
|
import User from '#models/user';
|
||||||
import { collectionValidator } from '#validators/collection';
|
import { collectionValidator } from '#validators/collection';
|
||||||
import type { HttpContext } from '@adonisjs/core/http';
|
import type { HttpContext } from '@adonisjs/core/http';
|
||||||
|
|
||||||
export default class CollectionsController {
|
export default class CollectionsController {
|
||||||
async index({ auth, inertia }: HttpContext) {
|
// Dashboard
|
||||||
const collections = await Collection.findManyBy('author_id', auth.user!.id);
|
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(
|
return inertia.render('dashboard', { collections });
|
||||||
collections.map(async (collection) => {
|
|
||||||
await collection.load('links');
|
|
||||||
return collection;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return inertia.render('dashboard', { collections: collectionsWithLinks });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create collection form
|
||||||
async showCreatePage({ inertia }: HttpContext) {
|
async showCreatePage({ inertia }: HttpContext) {
|
||||||
return inertia.render('collections/create');
|
return inertia.render('collections/create');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method called when creating a collection
|
||||||
async store({ request, response, auth }: HttpContext) {
|
async store({ request, response, auth }: HttpContext) {
|
||||||
const payload = await request.validateUsing(collectionValidator);
|
const payload = await request.validateUsing(collectionValidator);
|
||||||
const collection = await Collection.create({
|
const collection = await Collection.create({
|
||||||
@@ -30,7 +30,17 @@ export default class CollectionsController {
|
|||||||
return this.redirectToCollectionId(response, collection.id);
|
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'],
|
response: HttpContext['response'],
|
||||||
collectionId: Collection['id']
|
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 AppBaseModel from '#models/app_base_model';
|
||||||
import Link from '#models/link';
|
import Link from '#models/link';
|
||||||
import User from '#models/user';
|
import User from '#models/user';
|
||||||
import { belongsTo, column, manyToMany } from '@adonisjs/lucid/orm';
|
import { belongsTo, column, hasMany } from '@adonisjs/lucid/orm';
|
||||||
import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations';
|
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations';
|
||||||
import { Visibility } from '../enums/visibility.js';
|
import { Visibility } from '../enums/visibility.js';
|
||||||
|
|
||||||
export default class Collection extends AppBaseModel {
|
export default class Collection extends AppBaseModel {
|
||||||
@@ -24,6 +24,6 @@ export default class Collection extends AppBaseModel {
|
|||||||
@belongsTo(() => User, { foreignKey: 'authorId' })
|
@belongsTo(() => User, { foreignKey: 'authorId' })
|
||||||
declare author: BelongsTo<typeof User>;
|
declare author: BelongsTo<typeof User>;
|
||||||
|
|
||||||
@manyToMany(() => Link)
|
@hasMany(() => Link)
|
||||||
declare links: ManyToMany<typeof 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 styled from '@emotion/styled';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { TbLoader3 } from 'react-icons/tb';
|
import { TbLoader3 } from 'react-icons/tb';
|
||||||
import { TfiWorld } from 'react-icons/tfi';
|
import { TfiWorld } from 'react-icons/tfi';
|
||||||
|
import { rotate } from '~/styles/keyframes';
|
||||||
|
|
||||||
|
const IMG_LOAD_TIMEOUT = 7_500;
|
||||||
|
|
||||||
interface LinkFaviconProps {
|
interface LinkFaviconProps {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -22,7 +25,7 @@ const FaviconLoader = styled.div(({ theme }) => ({
|
|||||||
backgroundColor: theme.colors.white,
|
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) {
|
}: LinkFaviconProps) {
|
||||||
const [isFailed, setFailed] = useState<boolean>(false);
|
const [isFailed, setFailed] = useState<boolean>(false);
|
||||||
const [isLoading, setLoading] = useState<boolean>(true);
|
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 setFallbackFavicon = () => setFailed(true);
|
||||||
const handleStopLoading = () => setLoading(false);
|
const handleStopLoading = () => setLoading(false);
|
||||||
|
|
||||||
|
const handleErrorLoading = () => {
|
||||||
|
setFallbackFavicon();
|
||||||
|
handleStopLoading();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) return;
|
||||||
|
const id = setTimeout(() => handleErrorLoading(), IMG_LOAD_TIMEOUT);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Favicon style={{ marginRight: !noMargin ? '1em' : '0' }}>
|
<Favicon style={{ marginRight: !noMargin ? '1em' : '0' }}>
|
||||||
{!isFailed && baseUrlApi ? (
|
{!isFailed ? (
|
||||||
<img
|
<img
|
||||||
src={`${baseUrlApi}/favicon?urlParam=${url}`}
|
src={`/favicon?urlParam=${url}`}
|
||||||
onError={() => {
|
onError={handleErrorLoading}
|
||||||
setFallbackFavicon();
|
|
||||||
handleStopLoading();
|
|
||||||
}}
|
|
||||||
onLoad={handleStopLoading}
|
onLoad={handleStopLoading}
|
||||||
height={size}
|
height={size}
|
||||||
width={size}
|
width={size}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Collection from '#models/collection';
|
import type Collection from '#models/collection';
|
||||||
|
|
||||||
export const appendCollectionId = (
|
export const appendCollectionId = (
|
||||||
url: string,
|
url: string,
|
||||||
@@ -7,3 +7,15 @@ export const appendCollectionId = (
|
|||||||
|
|
||||||
export const appendResourceId = (url: string, resourceId?: string) =>
|
export const appendResourceId = (url: string, resourceId?: string) =>
|
||||||
`${url}${resourceId && `/${resourceId}`}}`;
|
`${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';
|
import { Visibility } from '../../../app/enums/visibility';
|
||||||
|
|
||||||
export default function CreateCollectionPage() {
|
export default function CreateCollectionPage() {
|
||||||
const { data, setData, post, processing, errors } = useForm({
|
const { data, setData, post, processing } = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
visibility: Visibility.PRIVATE,
|
visibility: Visibility.PRIVATE,
|
||||||
@@ -45,7 +45,6 @@ export default function CreateCollectionPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{errors.name && <div>{errors.name}</div>}
|
|
||||||
<TextBox
|
<TextBox
|
||||||
label="Collection description"
|
label="Collection description"
|
||||||
placeholder="Collection description"
|
placeholder="Collection description"
|
||||||
@@ -53,10 +52,14 @@ export default function CreateCollectionPage() {
|
|||||||
onChange={setData}
|
onChange={setData}
|
||||||
value={data.name}
|
value={data.name}
|
||||||
/>
|
/>
|
||||||
{errors.description && <div>{errors.description}</div>}
|
|
||||||
<FormField>
|
<FormField>
|
||||||
<label htmlFor="visibility">Public</label>
|
<label htmlFor="visibility">Public</label>
|
||||||
<input type="checkbox" onChange={handleOnCheck} id="visibility" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onChange={handleOnCheck}
|
||||||
|
value={data.visibility}
|
||||||
|
id="visibility"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</BackToDashboard>
|
</BackToDashboard>
|
||||||
</FormLayout>
|
</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,
|
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',
|
boxShadow: '0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #ffffff inset',
|
||||||
display: 'inline-block',
|
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: {
|
border: {
|
||||||
radius: '5px',
|
radius: '3px',
|
||||||
},
|
},
|
||||||
|
|
||||||
media: {
|
media: {
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "my-links",
|
"name": "my-links",
|
||||||
"version": "0.0.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "my-links",
|
"name": "my-links",
|
||||||
"version": "0.0.0",
|
"version": "1.3.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adonisjs/ally": "^5.0.2",
|
"@adonisjs/ally": "^5.0.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-links",
|
"name": "my-links",
|
||||||
"version": "0.0.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
<title inertia>MyLinks</title>
|
<title inertia>MyLinks</title>
|
||||||
|
|
||||||
@viteReactRefresh()
|
|
||||||
@inertiaHead()
|
@inertiaHead()
|
||||||
|
@viteReactRefresh()
|
||||||
@vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])
|
@vite(['inertia/app/app.tsx', `inertia/pages/${page.component}.tsx`])
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import PATHS from '#constants/paths';
|
|||||||
import { middleware } from '#start/kernel';
|
import { middleware } from '#start/kernel';
|
||||||
import router from '@adonisjs/core/services/router';
|
import router from '@adonisjs/core/services/router';
|
||||||
|
|
||||||
|
const LinksController = () => import('#controllers/links_controller');
|
||||||
const CollectionsController = () =>
|
const CollectionsController = () =>
|
||||||
import('#controllers/collections_controller');
|
import('#controllers/collections_controller');
|
||||||
const UsersController = () => import('#controllers/users_controller');
|
const UsersController = () => import('#controllers/users_controller');
|
||||||
@@ -22,5 +23,8 @@ router
|
|||||||
'showCreatePage',
|
'showCreatePage',
|
||||||
]);
|
]);
|
||||||
router.post('/collections', [CollectionsController, 'store']);
|
router.post('/collections', [CollectionsController, 'store']);
|
||||||
|
|
||||||
|
router.get(PATHS.LINK.CREATE, [LinksController, 'showCreatePage']);
|
||||||
|
router.post('/links', [LinksController, 'store']);
|
||||||
})
|
})
|
||||||
.middleware([middleware.auth()]);
|
.middleware([middleware.auth()]);
|
||||||
|
|||||||
Reference in New Issue
Block a user