This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
|
||||||
_commit: feeb1fb
|
_commit: ad95f52
|
||||||
_src_path: frontend-template
|
_src_path: /home/hideyoshi/Programming/Work/HideyoshiSolutions/frontend-template
|
||||||
author_email: vitor@hideyoshi.com.br
|
author_email: vitor@hideyoshi.com.br
|
||||||
author_github_url: https://github.com/HideyoshiNakazone
|
author_github_url: https://github.com/HideyoshiNakazone
|
||||||
author_name: Vitor Hideyoshi
|
author_name: Vitor Hideyoshi
|
||||||
@@ -10,4 +10,4 @@ copyright_year: 2026
|
|||||||
project_description: Personal Dev Blog
|
project_description: Personal Dev Blog
|
||||||
project_name: hideyoshi-blog
|
project_name: hideyoshi-blog
|
||||||
project_slug: hideyoshi-blog
|
project_slug: hideyoshi-blog
|
||||||
project_url: https://blog.hideyoshi.com.br
|
project_url: https://hideyoshi.com.br
|
||||||
|
|||||||
50
.github/workflows/test.yml
vendored
Normal file
50
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build and Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [20.x]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: 'true'
|
||||||
|
|
||||||
|
- name: Cache node modules
|
||||||
|
id: cache-npm
|
||||||
|
uses: actions/cache@v4
|
||||||
|
env:
|
||||||
|
cache-name: cache-node-modules
|
||||||
|
with:
|
||||||
|
# npm cache files are stored in `~/.npm` on Linux/macOS
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-${{ matrix.node-version }}-
|
||||||
|
${{ runner.os }}-node-
|
||||||
|
${{ runner.os }}-
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run Build
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: npm run test
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
"rtl": false,
|
||||||
"aliases": {
|
|
||||||
"components": "@/ui/components",
|
|
||||||
"ui": "@/ui/components/shadcn",
|
|
||||||
"lib": "@/ui/components/shadcn/lib",
|
|
||||||
"utils": "@/ui/components/shadcn/lib/utils",
|
|
||||||
"hooks": "@/ui/hooks"
|
|
||||||
},
|
|
||||||
"menuColor": "default",
|
"menuColor": "default",
|
||||||
"menuAccent": "subtle",
|
"menuAccent": "subtle",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/ui/components",
|
||||||
|
"utils": "@/ui/components/shadcn/lib/utils",
|
||||||
|
"ui": "@/ui/components/shadcn",
|
||||||
|
"lib": "@/ui/components/shadcn/lib",
|
||||||
|
"hooks": "@/ui/hooks"
|
||||||
|
},
|
||||||
"registries": {}
|
"registries": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
@import 'shadcn/tailwind.css';
|
@import 'shadcn/tailwind.css';
|
||||||
|
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-source-code-pro), sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
@apply font-bold;
|
||||||
|
font-family: var(--font-montserrat), sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -128,3 +144,19 @@
|
|||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.header-height {
|
||||||
|
@apply h-[8dvh] md:min-h-[60px];
|
||||||
|
}
|
||||||
|
.footer-height {
|
||||||
|
@apply md:h-24;
|
||||||
|
}
|
||||||
|
.page-height {
|
||||||
|
@apply min-h-[calc(100dvh-8dvh)];
|
||||||
|
}
|
||||||
|
.content-height {
|
||||||
|
@apply min-h-[calc(100dvh-8dvh-6rem)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function RootLayout({
|
|||||||
className={`${montserrat.className} ${sourceCodePro.className}`}
|
className={`${montserrat.className} ${sourceCodePro.className}`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className={'h-screen bg-background antialiased'}>
|
<body className={'relative h-screen bg-background antialiased'}>
|
||||||
<Provider
|
<Provider
|
||||||
attribute='class'
|
attribute='class'
|
||||||
defaultTheme='system'
|
defaultTheme='system'
|
||||||
@@ -52,7 +52,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<div className='h-full flex flex-col bg-background'>
|
<div className='h-full flex flex-col bg-background'>
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className='min-h-[90%] flex flex-col items-center justify-center'>
|
<div className='content-height flex flex-col items-center justify-center'>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import lightSiteIcon from '~/public/img/logo/red/icon-512.png';
|
import lightSiteIcon from '../public/img/logo/red/icon-512.png';
|
||||||
import darkSiteIcon from '~/public/img/logo/white/icon-512.png';
|
import darkSiteIcon from '../public/img/logo/white/icon-512.png';
|
||||||
|
|
||||||
export const siteConfig = {
|
export const siteConfig = {
|
||||||
shortName: 'hideyoshi-blog',
|
shortName: 'hideyoshi-blog',
|
||||||
name: 'hideyoshi-blog',
|
name: 'hideyoshi-blog',
|
||||||
slug: 'hideyoshi-blog',
|
slug: 'hideyoshi-blog',
|
||||||
description: 'Personal Dev Blog',
|
description: 'Personal Dev Blog',
|
||||||
url: process.env.FRONTEND_PATH || 'https://blog.hideyoshi.com.br',
|
url: process.env.FRONTEND_PATH || 'https://hideyoshi.com.br',
|
||||||
author: {
|
author: {
|
||||||
name: 'Vitor Hideyoshi',
|
name: 'Vitor Hideyoshi',
|
||||||
email: 'vitor@hideyoshi.com.br',
|
email: 'vitor@hideyoshi.com.br',
|
||||||
@@ -35,7 +35,7 @@ export const siteConfig = {
|
|||||||
},
|
},
|
||||||
copyright: {
|
copyright: {
|
||||||
company: '',
|
company: '',
|
||||||
initialYear: '2026',
|
initialYear: 2026,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { saveArticle } from '@/lib/feature/article/article.external';
|
|||||||
import { Button } from '@/ui/components/shadcn/button';
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
|
FieldDescription,
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
@@ -14,19 +15,21 @@ import {
|
|||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
} from '@/ui/components/shadcn/input-group';
|
} from '@/ui/components/shadcn/input-group';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const CreateArticleForm = () => {
|
export const CreateArticleForm = () => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
title: z.string().min(3).max(255),
|
title: z.string().min(3).max(255),
|
||||||
slug: z.string().min(3),
|
slug: z.string().min(3),
|
||||||
description: z.string().min(10),
|
description: z.string().min(10),
|
||||||
coverImageUrl: z.string().url(),
|
coverImageUrl: z.url(),
|
||||||
content: z.string().min(10),
|
content: z.instanceof(File),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
@@ -36,7 +39,7 @@ export const CreateArticleForm = () => {
|
|||||||
slug: '',
|
slug: '',
|
||||||
description: '',
|
description: '',
|
||||||
coverImageUrl: '',
|
coverImageUrl: '',
|
||||||
content: '',
|
content: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,27 +53,41 @@ export const CreateArticleForm = () => {
|
|||||||
form.setValue('slug', slugify(title).toLowerCase());
|
form.setValue('slug', slugify(title).toLowerCase());
|
||||||
}, [form, title]);
|
}, [form, title]);
|
||||||
|
|
||||||
async function onSubmit(data: z.infer<typeof formSchema>) {
|
const handleFormSubmit = useCallback(
|
||||||
try {
|
async (data: z.infer<typeof formSchema>) => {
|
||||||
const result = await saveArticle(data);
|
try {
|
||||||
toast.success('Article created successfully!', {
|
const result = await saveArticle({
|
||||||
description: `Article "${result.title}" has been created.`,
|
...data,
|
||||||
position: 'bottom-right',
|
content: await data.content.text(),
|
||||||
});
|
});
|
||||||
form.reset();
|
toast.success('Article created successfully!', {
|
||||||
} catch (error) {
|
description: `Article "${result.title}" has been created.`,
|
||||||
toast.error('Failed to create article', {
|
position: 'bottom-right',
|
||||||
description:
|
});
|
||||||
error instanceof Error
|
form.reset();
|
||||||
? error.message
|
|
||||||
: 'An error occurred',
|
if (fileInputRef.current) {
|
||||||
position: 'bottom-right',
|
fileInputRef.current.value = '';
|
||||||
});
|
}
|
||||||
}
|
} catch (error) {
|
||||||
}
|
toast.error('Failed to create article', {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'An error occurred',
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id='form-create-article' onSubmit={form.handleSubmit(onSubmit)}>
|
<form
|
||||||
|
id='form-create-article'
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
|
>
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
<Controller
|
<Controller
|
||||||
name='title'
|
name='title'
|
||||||
@@ -166,18 +183,24 @@ export const CreateArticleForm = () => {
|
|||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<Field data-invalid={fieldState.invalid}>
|
<Field data-invalid={fieldState.invalid}>
|
||||||
<FieldLabel htmlFor='form-create-article-content'>
|
<FieldLabel htmlFor='form-create-article-content'>
|
||||||
Content
|
Content (Markdown File)
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<InputGroup>
|
<Input
|
||||||
<InputGroupTextarea
|
ref={fileInputRef}
|
||||||
{...field}
|
id='form-create-article-content'
|
||||||
id='form-create-article-content'
|
type='file'
|
||||||
placeholder='Write your article content here...'
|
accept='.md,.markdown'
|
||||||
rows={12}
|
aria-invalid={fieldState.invalid}
|
||||||
className='min-h-48 resize-none font-mono text-sm'
|
onChange={(event) =>
|
||||||
aria-invalid={fieldState.invalid}
|
field.onChange(
|
||||||
/>
|
event.target.files &&
|
||||||
</InputGroup>
|
event.target.files[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldDescription>
|
||||||
|
Select your article.
|
||||||
|
</FieldDescription>
|
||||||
{fieldState.invalid && (
|
{fieldState.invalid && (
|
||||||
<FieldError errors={[fieldState.error]} />
|
<FieldError errors={[fieldState.error]} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { BlankUserButton } from '@/ui/components/internal/user-profile/user-prof
|
|||||||
|
|
||||||
export const StaticDesktopHeader = () => {
|
export const StaticDesktopHeader = () => {
|
||||||
const links = [
|
const links = [
|
||||||
{ href: '/home', label: 'Home' },
|
{ href: '/home', label: 'Home', condition: true },
|
||||||
{ href: '/about', label: 'About' },
|
{ href: '/about', label: 'About', condition: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
return <BaseDesktopHeader links={links} userButton={<BlankUserButton />} />;
|
return <BaseDesktopHeader links={links} userButton={<BlankUserButton />} />;
|
||||||
|
|||||||
1441
src/ui/components/shadcn/file-upload.tsx
Normal file
1441
src/ui/components/shadcn/file-upload.tsx
Normal file
File diff suppressed because it is too large
Load Diff
14
src/ui/hooks/use-as-ref.ts
Normal file
14
src/ui/hooks/use-as-ref.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useIsomorphicLayoutEffect } from '@/ui/hooks/use-isomorphic-layout-effect';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function useAsRef<T>(props: T) {
|
||||||
|
const ref = React.useRef<T>(props);
|
||||||
|
|
||||||
|
useIsomorphicLayoutEffect(() => {
|
||||||
|
ref.current = props;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useAsRef };
|
||||||
6
src/ui/hooks/use-isomorphic-layout-effect.ts
Normal file
6
src/ui/hooks/use-isomorphic-layout-effect.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const useIsomorphicLayoutEffect =
|
||||||
|
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
||||||
|
|
||||||
|
export { useIsomorphicLayoutEffect };
|
||||||
13
src/ui/hooks/use-lazy-ref.ts
Normal file
13
src/ui/hooks/use-lazy-ref.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function useLazyRef<T>(fn: () => T) {
|
||||||
|
const ref = React.useRef<T | null>(null);
|
||||||
|
|
||||||
|
if (ref.current === null) {
|
||||||
|
ref.current = fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref as React.RefObject<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useLazyRef };
|
||||||
Reference in New Issue
Block a user