ligun and sogup
This commit is contained in:
parent
b6633d6f25
commit
4573048a47
24 changed files with 482 additions and 226 deletions
|
@ -1,13 +1,16 @@
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router'
|
import { BrowserRouter, Route, Routes } from 'react-router'
|
||||||
import HomePage from './pages/HomePage.tsx'
|
import HomePage from './pages/HomePage.tsx'
|
||||||
import { PostsService } from './model/posts/postsService.ts'
|
import { PostsService } from './feed/models/posts/postsService.ts'
|
||||||
import AuthorPage from './pages/AuthorPage.tsx'
|
import AuthorPage from './pages/AuthorPage.tsx'
|
||||||
import { MediaService } from './model/mediaService.ts'
|
import { MediaService } from './model/media/mediaService.ts'
|
||||||
import SignupPage from './pages/SignupPage.tsx'
|
import SignupPage from './pages/SignupPage.tsx'
|
||||||
|
import LoginPage from './pages/LoginPage.tsx'
|
||||||
|
import { AuthService } from './auth/authService.ts'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const postService = new PostsService()
|
const postService = new PostsService()
|
||||||
const mediaService = new MediaService()
|
const mediaService = new MediaService()
|
||||||
|
const authService = new AuthService()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
@ -17,6 +20,7 @@ function App() {
|
||||||
element={<HomePage postsService={postService} mediaService={mediaService} />}
|
element={<HomePage postsService={postService} mediaService={mediaService} />}
|
||||||
/>
|
/>
|
||||||
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
|
<Route path="/u/:username" element={<AuthorPage postsService={postService} />} />
|
||||||
|
<Route path="/login" element={<LoginPage authService={authService} />} />
|
||||||
<Route path="/signup/:code?" element={<SignupPage />} />
|
<Route path="/signup/:code?" element={<SignupPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|
|
@ -6,25 +6,15 @@ const ApiHost = `http://${location.hostname}:5181`
|
||||||
|
|
||||||
console.debug('API HOST IS', ApiHost)
|
console.debug('API HOST IS', ApiHost)
|
||||||
|
|
||||||
export async function loadPostsForAuthor(
|
|
||||||
authorId: string,
|
|
||||||
cursor: string | null,
|
|
||||||
amount: number | null,
|
|
||||||
): Promise<components['schemas']['GetAuthorPostsResponse']> {
|
|
||||||
const url = new URL(`authors/${authorId}/posts`, ApiHost)
|
|
||||||
if (amount != null) url.searchParams.set('amount', amount.toString())
|
|
||||||
if (cursor != null) url.searchParams.set('from', cursor)
|
|
||||||
const res = await doGetRequest(url)
|
|
||||||
return res as components['schemas']['GetAuthorPostsResponse']
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPublicFeed(
|
export async function loadPublicFeed(
|
||||||
cursor: string | null,
|
cursor: string | null,
|
||||||
amount: number | null,
|
amount: number | null,
|
||||||
|
author: string | null,
|
||||||
): Promise<components['schemas']['GetAllPublicPostsResponse']> {
|
): Promise<components['schemas']['GetAllPublicPostsResponse']> {
|
||||||
const url = new URL(`posts`, ApiHost)
|
const url = new URL(`posts`, ApiHost)
|
||||||
if (amount != null) url.searchParams.set('amount', amount.toString())
|
if (amount != null) url.searchParams.set('amount', amount.toString())
|
||||||
if (cursor != null) url.searchParams.set('from', cursor)
|
if (cursor != null) url.searchParams.set('from', cursor)
|
||||||
|
if (author != null) url.searchParams.set('author', author)
|
||||||
|
|
||||||
const res = await doGetRequest(url)
|
const res = await doGetRequest(url)
|
||||||
|
|
||||||
|
@ -51,7 +41,7 @@ export async function uploadMedia(
|
||||||
export async function createPost(
|
export async function createPost(
|
||||||
authorId: string,
|
authorId: string,
|
||||||
content: string,
|
content: string,
|
||||||
media: string[],
|
media: components['schemas']['CreatePostRequestMedia'][],
|
||||||
): Promise<components['schemas']['CreatePostResponse']> {
|
): Promise<components['schemas']['CreatePostResponse']> {
|
||||||
const url = new URL('posts', ApiHost)
|
const url = new URL('posts', ApiHost)
|
||||||
const body: components['schemas']['CreatePostRequest'] = {
|
const body: components['schemas']['CreatePostRequest'] = {
|
||||||
|
|
|
@ -11,6 +11,8 @@ export interface paths {
|
||||||
query?: {
|
query?: {
|
||||||
From?: string
|
From?: string
|
||||||
Amount?: number
|
Amount?: number
|
||||||
|
AuthorId?: string
|
||||||
|
Author?: string
|
||||||
}
|
}
|
||||||
header?: never
|
header?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
@ -145,71 +147,26 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
'/authors/{username}/posts': {
|
|
||||||
parameters: {
|
|
||||||
query?: never
|
|
||||||
header?: never
|
|
||||||
path?: never
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
get: {
|
|
||||||
parameters: {
|
|
||||||
query?: {
|
|
||||||
From?: string
|
|
||||||
Amount?: number
|
|
||||||
}
|
|
||||||
header?: never
|
|
||||||
path: {
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
cookie?: never
|
|
||||||
}
|
|
||||||
requestBody?: never
|
|
||||||
responses: {
|
|
||||||
/** @description OK */
|
|
||||||
200: {
|
|
||||||
headers: {
|
|
||||||
[name: string]: unknown
|
|
||||||
}
|
|
||||||
content: {
|
|
||||||
'text/plain': components['schemas']['GetAuthorPostsResponse']
|
|
||||||
'application/json': components['schemas']['GetAuthorPostsResponse']
|
|
||||||
'text/json': components['schemas']['GetAuthorPostsResponse']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
put?: never
|
|
||||||
post?: never
|
|
||||||
delete?: never
|
|
||||||
options?: never
|
|
||||||
head?: never
|
|
||||||
patch?: never
|
|
||||||
trace?: never
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>
|
export type webhooks = Record<string, never>
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
AuthoPostAuthorDto: {
|
|
||||||
/** Format: uuid */
|
|
||||||
authorId: string
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
AuthorPostDto: {
|
|
||||||
/** Format: uuid */
|
|
||||||
postId: string
|
|
||||||
content: string
|
|
||||||
media: string[]
|
|
||||||
/** Format: date-time */
|
|
||||||
createdAt: string
|
|
||||||
author: components['schemas']['AuthoPostAuthorDto']
|
|
||||||
}
|
|
||||||
CreatePostRequest: {
|
CreatePostRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
authorId: string
|
authorId: string
|
||||||
content: string
|
content: string
|
||||||
media: string[]
|
media: components['schemas']['CreatePostRequestMedia'][]
|
||||||
|
}
|
||||||
|
CreatePostRequestMedia: {
|
||||||
|
/** Format: uuid */
|
||||||
|
mediaId: string
|
||||||
|
/** Format: uri */
|
||||||
|
url: string
|
||||||
|
type: string | null
|
||||||
|
/** Format: int32 */
|
||||||
|
width: number | null
|
||||||
|
/** Format: int32 */
|
||||||
|
height: number | null
|
||||||
}
|
}
|
||||||
CreatePostResponse: {
|
CreatePostResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
|
@ -220,11 +177,6 @@ export interface components {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
next: string | null
|
next: string | null
|
||||||
}
|
}
|
||||||
GetAuthorPostsResponse: {
|
|
||||||
posts: components['schemas']['AuthorPostDto'][]
|
|
||||||
/** Format: uuid */
|
|
||||||
next: string | null
|
|
||||||
}
|
|
||||||
PublicPostAuthorDto: {
|
PublicPostAuthorDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
authorId: string
|
authorId: string
|
||||||
|
@ -235,10 +187,18 @@ export interface components {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
postId: string
|
postId: string
|
||||||
content: string
|
content: string
|
||||||
media: string[]
|
media: components['schemas']['PublicPostMediaDto'][]
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
PublicPostMediaDto: {
|
||||||
|
/** Format: uri */
|
||||||
|
url: string
|
||||||
|
/** Format: int32 */
|
||||||
|
width: number | null
|
||||||
|
/** Format: int32 */
|
||||||
|
height: number | null
|
||||||
|
}
|
||||||
UploadMediaResponse: {
|
UploadMediaResponse: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
mediaId: string
|
mediaId: string
|
||||||
|
|
9
src/auth/authService.ts
Normal file
9
src/auth/authService.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export class AuthService {
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
|
||||||
|
async signup(username: string, password: string, signupCode: string, email?: string) {
|
||||||
|
throw new Error('not implemented')
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,10 @@
|
||||||
import { Link } from 'react-router'
|
import NavLinkButton from './NavLinkButton'
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
return (
|
return (
|
||||||
<nav className={`w-full flex flex-row-reverse px-4 md:px-8 py-0.5`}>
|
<nav className={`w-full flex flex-row-reverse gap-4 px-4 md:px-8 py-0.5`}>
|
||||||
<Link className={`text-gray-800`} to="/signup">
|
<NavLinkButton to="/signup">register</NavLinkButton>
|
||||||
create account
|
<NavLinkButton to="/login">login</NavLinkButton>
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
13
src/components/NavLinkButton.tsx
Normal file
13
src/components/NavLinkButton.tsx
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { PropsWithChildren } from 'react'
|
||||||
|
import { Link } from 'react-router'
|
||||||
|
interface NavLinkButtonProps {
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavLinkButton({ to, children }: PropsWithChildren<NavLinkButtonProps>) {
|
||||||
|
return (
|
||||||
|
<Link className={`text-primary-500`} to={to}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ import SecondaryButton from './SecondaryButton.tsx'
|
||||||
import { openFileDialog } from '../utils/openFileDialog.ts'
|
import { openFileDialog } from '../utils/openFileDialog.ts'
|
||||||
|
|
||||||
interface NewPostWidgetProps {
|
interface NewPostWidgetProps {
|
||||||
onSubmit: (content: string, media: File[]) => void
|
onSubmit: (content: string, media: { file: File; width: number; height: number }[]) => void
|
||||||
isSubmitting?: boolean
|
isSubmitting?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,6 +13,8 @@ interface Attachment {
|
||||||
id: string
|
id: string
|
||||||
file: File
|
file: File
|
||||||
objectUrl: string
|
objectUrl: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
|
export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPostWidgetProps) {
|
||||||
|
@ -29,13 +31,8 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newFiles = Array.from(files).map((file) => ({
|
const newAttachments = await Promise.all(Array.from(files).map(createAttachment))
|
||||||
id: crypto.randomUUID(),
|
setAttachments((attachments) => [...attachments, ...newAttachments])
|
||||||
file,
|
|
||||||
objectUrl: URL.createObjectURL(file),
|
|
||||||
}))
|
|
||||||
|
|
||||||
setAttachments((attachments) => [...attachments, ...newFiles])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
@ -43,10 +40,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(
|
onSubmit(content, attachments)
|
||||||
content,
|
|
||||||
attachments.map(({ file }) => file),
|
|
||||||
)
|
|
||||||
|
|
||||||
attachments.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl))
|
attachments.forEach(({ objectUrl }) => URL.revokeObjectURL(objectUrl))
|
||||||
setContent('')
|
setContent('')
|
||||||
|
@ -91,7 +85,7 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center pt-2">
|
||||||
<SecondaryButton onClick={onAddMediaClicked}>+ add media</SecondaryButton>
|
<SecondaryButton onClick={onAddMediaClicked}>+ add media</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
@ -103,3 +97,33 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createAttachment(file: File): Promise<Attachment> {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
throw new Error('not an image')
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
const { width, height } = await getImageFileDimensions(objectUrl)
|
||||||
|
|
||||||
|
console.debug('width', width, 'height', height)
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
file,
|
||||||
|
objectUrl,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageFileDimensions(objectURL: string): Promise<{ width: number; height: number }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
resolve({ width: img.width, height: img.height })
|
||||||
|
})
|
||||||
|
|
||||||
|
img.src = objectURL
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { Post } from '../model/posts/posts.ts'
|
|
||||||
import PostItem from './PostItem'
|
|
||||||
|
|
||||||
interface PostsFeedProps {
|
|
||||||
pages: Post[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PostsList({ pages }: PostsFeedProps) {
|
|
||||||
return <div className="flex flex-col gap-6 w-full">{pages.map(renderPage)}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPage(posts: Post[]) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 w-full">
|
|
||||||
{posts.map((post, idx) => (
|
|
||||||
<PostItem key={post.postId} post={post} index={idx} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { Ref } from 'react'
|
||||||
|
|
||||||
interface TextInputProps {
|
interface TextInputProps {
|
||||||
|
ref?: Ref<HTMLInputElement>
|
||||||
id?: string
|
id?: string
|
||||||
type?: 'text' | 'email' | 'password'
|
type?: 'text' | 'email' | 'password'
|
||||||
value: string
|
value: string
|
||||||
|
@ -10,6 +13,7 @@ interface TextInputProps {
|
||||||
|
|
||||||
export default function TextInput({
|
export default function TextInput({
|
||||||
id,
|
id,
|
||||||
|
ref,
|
||||||
value,
|
value,
|
||||||
onInput,
|
onInput,
|
||||||
className: extraClasses = '',
|
className: extraClasses = '',
|
||||||
|
@ -19,6 +23,7 @@ export default function TextInput({
|
||||||
}: TextInputProps) {
|
}: TextInputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
value={value}
|
value={value}
|
||||||
type={type}
|
type={type}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { Post } from '../model/posts/posts.ts'
|
import { Post } from './models/posts/posts.ts'
|
||||||
|
|
||||||
const PageSize = 20
|
const PageSize = 20
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import PostsList from '../components/PostsList.tsx'
|
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts'
|
import { useIntersectionLoad } from '../hooks/useIntersectionLoad.ts'
|
||||||
import { Post } from '../model/posts/posts.ts'
|
import { Post } from './models/posts/posts.ts'
|
||||||
|
import PostItem from './PostItem.tsx'
|
||||||
|
|
||||||
interface FeedViewProps {
|
interface FeedViewProps {
|
||||||
pages: Post[][]
|
pages: Post[][]
|
||||||
|
@ -10,12 +10,19 @@ interface FeedViewProps {
|
||||||
|
|
||||||
export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
|
export default function FeedView({ pages, onLoadMore }: FeedViewProps) {
|
||||||
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const posts = pages.flat()
|
||||||
|
|
||||||
useIntersectionLoad(onLoadMore, sentinelRef)
|
useIntersectionLoad(onLoadMore, sentinelRef)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<PostsList pages={pages} />
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<PostItem key={post.postId} post={post} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
import { Post } from '../model/posts/posts.ts'
|
import { Post, PostMedia } from './models/posts/posts.ts'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
interface PostItemProps {
|
interface PostItemProps {
|
||||||
post: Post
|
post: Post
|
||||||
index: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostItem({ post, index }: PostItemProps) {
|
export default function PostItem({ post }: PostItemProps) {
|
||||||
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
const formattedDate = post.createdAt.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
@ -20,19 +19,15 @@ export default function PostItem({ post, index }: PostItemProps) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(() => setVisible(true))
|
const timeout = setTimeout(() => setVisible(true))
|
||||||
return () => clearTimeout(timeout)
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const opacity = visible ? 'opacity-100' : 'opacity-0'
|
const opacity = visible ? 'opacity-100' : 'opacity-0'
|
||||||
|
|
||||||
const delayMs = index * 100
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article className={`w-full p-4 ${opacity} transition-opacity duration-500`} key={post.postId}>
|
||||||
className={`w-full p-4 ${opacity} transition-opacity duration-500`}
|
|
||||||
style={{ transitionDelay: `${delayMs}ms` }}
|
|
||||||
key={post.postId}
|
|
||||||
>
|
|
||||||
<div className="text-sm text-gray-500 mb-3">
|
<div className="text-sm text-gray-500 mb-3">
|
||||||
<Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2">
|
<Link to={`/u/${post.authorName}`} className="text-gray-400 hover:underline mr-2">
|
||||||
@{post.authorName}
|
@{post.authorName}
|
||||||
|
@ -44,17 +39,31 @@ export default function PostItem({ post, index }: PostItemProps) {
|
||||||
|
|
||||||
{post.media.length > 0 && (
|
{post.media.length > 0 && (
|
||||||
<div className="grid gap-4 grid-cols-1">
|
<div className="grid gap-4 grid-cols-1">
|
||||||
{post.media.map((src) => (
|
{post.media.map((media) => (
|
||||||
<img
|
<PostMediaItem key={media.url.toString()} media={media} />
|
||||||
key={src.toString()}
|
|
||||||
src={src.toString()}
|
|
||||||
alt="todo sry :("
|
|
||||||
className="w-full h-auto"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PostMediaProps {
|
||||||
|
media: PostMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostMediaItem({ media }: PostMediaProps) {
|
||||||
|
const url = media.url.toString()
|
||||||
|
const width = media.width ?? undefined
|
||||||
|
const height = media.height ?? undefined
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
src={url}
|
||||||
|
alt="todo sry :("
|
||||||
|
className="w-full h-auto"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,17 +1,17 @@
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
import { components } from '../../api/schema.ts'
|
import { components } from '../../../api/schema.ts'
|
||||||
|
|
||||||
export class Post {
|
export class Post {
|
||||||
public readonly postId: string
|
public readonly postId: string
|
||||||
public readonly content: string
|
public readonly content: string
|
||||||
public readonly media: URL[]
|
public readonly media: PostMedia[]
|
||||||
public readonly createdAt: Temporal.Instant
|
public readonly createdAt: Temporal.Instant
|
||||||
public readonly authorName: string
|
public readonly authorName: string
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
postId: string,
|
postId: string,
|
||||||
content: string,
|
content: string,
|
||||||
media: URL[],
|
media: PostMedia[],
|
||||||
createdAt: string | Temporal.Instant,
|
createdAt: string | Temporal.Instant,
|
||||||
authorName: string,
|
authorName: string,
|
||||||
) {
|
) {
|
||||||
|
@ -22,15 +22,28 @@ export class Post {
|
||||||
this.authorName = authorName
|
this.authorName = authorName
|
||||||
}
|
}
|
||||||
|
|
||||||
public static fromDto(
|
public static fromDto(dto: components['schemas']['PublicPostDto']): Post {
|
||||||
dto: components['schemas']['AuthorPostDto'] | components['schemas']['PublicPostDto'],
|
console.debug('make post', dto)
|
||||||
): Post {
|
|
||||||
return new Post(
|
return new Post(
|
||||||
dto.postId,
|
dto.postId,
|
||||||
dto.content,
|
dto.content,
|
||||||
dto.media.map((url) => new URL(url)),
|
dto.media.map((m) => new PostMediaImpl(new URL(m.url), m.width, m.height)),
|
||||||
Temporal.Instant.from(dto.createdAt),
|
Temporal.Instant.from(dto.createdAt),
|
||||||
dto.author.username,
|
dto.author.username,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostMedia {
|
||||||
|
readonly url: URL
|
||||||
|
readonly width: number | null
|
||||||
|
readonly height: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class PostMediaImpl implements PostMedia {
|
||||||
|
constructor(
|
||||||
|
readonly url: URL,
|
||||||
|
readonly width: number | null,
|
||||||
|
readonly height: number | null,
|
||||||
|
) {}
|
||||||
|
}
|
36
src/feed/models/posts/postsService.ts
Normal file
36
src/feed/models/posts/postsService.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { createPost, loadPublicFeed } from '../../../api/api.ts'
|
||||||
|
import { Post } from './posts.ts'
|
||||||
|
|
||||||
|
export class PostsService {
|
||||||
|
async createNew(authorId: string, content: string, media: CreatePostMedia[]): Promise<string> {
|
||||||
|
const { postId } = await createPost(
|
||||||
|
authorId,
|
||||||
|
content,
|
||||||
|
media.map((m) => {
|
||||||
|
return { ...m, type: null, url: m.url.toString() }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return postId
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<Post[]> {
|
||||||
|
const result = await loadPublicFeed(cursor, amount, null)
|
||||||
|
return result.posts.map((post) => Post.fromDto(post))
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadByAuthor(
|
||||||
|
username: string,
|
||||||
|
cursor: string | null,
|
||||||
|
amount: number | null,
|
||||||
|
): Promise<Post[]> {
|
||||||
|
const result = await loadPublicFeed(cursor, amount, username)
|
||||||
|
return result.posts.map((post) => Post.fromDto(post))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatePostMedia {
|
||||||
|
mediaId: string
|
||||||
|
url: string | URL
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ export function useIntersectionLoad(
|
||||||
callback: () => Promise<void>,
|
callback: () => Promise<void>,
|
||||||
elementRef: RefObject<HTMLElement | null>,
|
elementRef: RefObject<HTMLElement | null>,
|
||||||
{
|
{
|
||||||
earlyTriggerPx = 1800,
|
earlyTriggerPx = 1200,
|
||||||
debounceMs = 300,
|
debounceMs = 300,
|
||||||
root = null,
|
root = null,
|
||||||
threshold = 0.1,
|
threshold = 0.1,
|
||||||
|
|
8
src/model/media/mediaService.ts
Normal file
8
src/model/media/mediaService.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { uploadMedia } from '../../api/api.ts'
|
||||||
|
|
||||||
|
export class MediaService {
|
||||||
|
async uploadFile(file: File): Promise<{ mediaId: string; url: URL }> {
|
||||||
|
const { mediaId, url } = await uploadMedia(file)
|
||||||
|
return { mediaId, url: new URL(url) }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,8 +0,0 @@
|
||||||
import { uploadMedia } from '../api/api.ts'
|
|
||||||
|
|
||||||
export class MediaService {
|
|
||||||
async uploadFile(file: File): Promise<URL> {
|
|
||||||
const { url } = await uploadMedia(file)
|
|
||||||
return new URL(url)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { createPost, loadPostsForAuthor, loadPublicFeed } from '../../api/api.ts'
|
|
||||||
import { Post } from './posts.ts'
|
|
||||||
|
|
||||||
export class PostsService {
|
|
||||||
async createNew(authorId: string, content: string, media: URL[]): Promise<string> {
|
|
||||||
const { postId } = await createPost(
|
|
||||||
authorId,
|
|
||||||
content,
|
|
||||||
media.map((url) => url.toString()),
|
|
||||||
)
|
|
||||||
return postId
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadPublicFeed(cursor: string | null, amount: number | null): Promise<Post[]> {
|
|
||||||
const result = await loadPublicFeed(cursor, amount)
|
|
||||||
return result.posts.map((post) => Post.fromDto(post))
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadByAuthor(
|
|
||||||
username: string,
|
|
||||||
cursor: string | null,
|
|
||||||
amount: number | null,
|
|
||||||
): Promise<Post[]> {
|
|
||||||
const result = await loadPostsForAuthor(username!, cursor, amount)
|
|
||||||
return result.posts.map((post) => Post.fromDto(post))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import FeedView from '../feed/FeedView.tsx'
|
import FeedView from '../feed/FeedView.tsx'
|
||||||
import { PostsService } from '../model/posts/postsService.ts'
|
import { PostsService } from '../feed/models/posts/postsService.ts'
|
||||||
import { useParams } from 'react-router'
|
import { useParams } from 'react-router'
|
||||||
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||||
import NavBar from '../components/NavBar.tsx'
|
import NavBar from '../components/NavBar.tsx'
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import FeedView from '../feed/FeedView.tsx'
|
import FeedView from '../feed/FeedView.tsx'
|
||||||
import { PostsService } from '../model/posts/postsService.ts'
|
import { PostsService } from '../feed/models/posts/postsService.ts'
|
||||||
import { useUserStore } from '../store/userStore.ts'
|
import { useUserStore } from '../store/userStore.ts'
|
||||||
import { MediaService } from '../model/mediaService.ts'
|
import { MediaService } from '../model/media/mediaService.ts'
|
||||||
import NewPostWidget from '../components/NewPostWidget.tsx'
|
import NewPostWidget from '../components/NewPostWidget.tsx'
|
||||||
import { useFeedViewModel } from '../feed/FeedView.ts'
|
import { useFeedViewModel } from '../feed/FeedView.ts'
|
||||||
import { Post } from '../model/posts/posts.ts'
|
import { Post } from '../feed/models/posts/posts.ts'
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||||
import NavBar from '../components/NavBar.tsx'
|
import NavBar from '../components/NavBar.tsx'
|
||||||
|
@ -30,15 +30,25 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
||||||
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
|
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
|
||||||
|
|
||||||
const onCreatePost = useCallback(
|
const onCreatePost = useCallback(
|
||||||
async (content: string, files: File[]) => {
|
async (content: string, files: { file: File; width: number; height: number }[]) => {
|
||||||
if (!onCreatePost) return
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
|
||||||
if (user == null) throw new Error('Not logged in')
|
if (user == null) throw new Error('Not logged in')
|
||||||
const urls = await Promise.all(files.map((file) => mediaService.uploadFile(file)))
|
try {
|
||||||
const postId = await postsService.createNew(user.userId, content, urls)
|
const media = await Promise.all(
|
||||||
const post = new Post(postId, content, urls, Temporal.Now.instant(), user.username)
|
files.map(async ({ file, width, height }) => {
|
||||||
|
console.debug('do mediaService.uploadFile', file, 'width', width, 'height', height)
|
||||||
|
const { mediaId, url } = await mediaService.uploadFile(file)
|
||||||
|
|
||||||
|
return {
|
||||||
|
mediaId,
|
||||||
|
url,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const postId = await postsService.createNew(user.userId, content, media)
|
||||||
|
const post = new Post(postId, content, media, Temporal.Now.instant(), user.username)
|
||||||
setPages((pages) => [[post], ...pages])
|
setPages((pages) => [[post], ...pages])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create post:', error)
|
console.error('Failed to create post:', error)
|
||||||
|
|
85
src/pages/LoginPage.tsx
Normal file
85
src/pages/LoginPage.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { useRef, useState, FormEvent } from 'react'
|
||||||
|
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||||
|
import TextInput from '../components/TextInput.tsx'
|
||||||
|
import PrimaryButton from '../components/PrimaryButton.tsx'
|
||||||
|
import { AuthService } from '../auth/authService.ts'
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
authService: AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage({ authService }: LoginPageProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const usernameInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
setError("you didn't username D:<")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
setError("you didn't password D:<")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginResult = await authService.login(username, password)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(error instanceof Error ? error.message : 'something went terribly wrong')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SingleColumnLayout>
|
||||||
|
<main className="w-full mx-auto p-4">
|
||||||
|
<div className="mt-12">
|
||||||
|
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="username" className="text-sm text-gray-600">
|
||||||
|
username
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
ref={usernameInputRef}
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onInput={setUsername}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor="password" className="text-sm text-gray-600">
|
||||||
|
password
|
||||||
|
</label>
|
||||||
|
<TextInput
|
||||||
|
ref={passwordInputRef}
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onInput={setPassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton className="mt-4" disabled={isSubmitting} type="submit">
|
||||||
|
{isSubmitting ? 'wait...' : 'make login pls'}
|
||||||
|
</PrimaryButton>
|
||||||
|
|
||||||
|
<span className="text-xs h-3 text-red-500">{error}</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</SingleColumnLayout>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,9 +1,10 @@
|
||||||
import { useParams } from 'react-router'
|
import { useParams } from 'react-router'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, FormEvent, useCallback, Ref } from 'react'
|
||||||
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||||
import TextInput from '../components/TextInput.tsx'
|
import TextInput from '../components/TextInput.tsx'
|
||||||
import PrimaryButton from '../components/PrimaryButton.tsx'
|
import PrimaryButton from '../components/PrimaryButton.tsx'
|
||||||
import PrimaryLinkButton from '../components/PrimaryLinkButton.tsx'
|
import PrimaryLinkButton from '../components/PrimaryLinkButton.tsx'
|
||||||
|
import { invalid, valid, Validation } from '../utils/validation.ts'
|
||||||
|
|
||||||
const SignupCodeKey = 'signupCode'
|
const SignupCodeKey = 'signupCode'
|
||||||
|
|
||||||
|
@ -12,9 +13,17 @@ export default function SignupPage() {
|
||||||
const [signupCode, setSignupCode] = useState<string | null>(null)
|
const [signupCode, setSignupCode] = useState<string | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername, usernameError, validateUsername] =
|
||||||
const [email, setEmail] = useState('')
|
useValidatedInput(isValidUsername)
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
|
const [email, setEmail, emailError, validateEmail] = useValidatedInput(isValidEmail)
|
||||||
|
|
||||||
|
const [password, setPassword, passwordError, validatePassword] =
|
||||||
|
useValidatedInput(isValidPassword)
|
||||||
|
|
||||||
|
const userNameInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const emailInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const passwordInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null)
|
const dialogRef = useRef<HTMLDialogElement | null>(null)
|
||||||
|
|
||||||
|
@ -35,8 +44,29 @@ export default function SignupPage() {
|
||||||
}
|
}
|
||||||
}, [code, signupCode])
|
}, [code, signupCode])
|
||||||
|
|
||||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
const isUsernameValid = validateUsername()
|
||||||
|
const isEmailValid = validateEmail()
|
||||||
|
const isPasswordValid = validatePassword()
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
passwordInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmailValid) {
|
||||||
|
emailInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUsernameValid) {
|
||||||
|
userNameInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUsernameValid || !isEmailValid || !isPasswordValid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -51,34 +81,33 @@ export default function SignupPage() {
|
||||||
<main className="w-full mx-auto p-4">
|
<main className="w-full mx-auto p-4">
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
<form className="flex flex-col gap-4 max-w-md" onSubmit={onSubmit}>
|
||||||
<div className="flex flex-col gap-2">
|
<FormInput
|
||||||
<label htmlFor="username" className="text-sm text-gray-600">
|
id="username"
|
||||||
username
|
value={username}
|
||||||
</label>
|
onInput={setUsername}
|
||||||
<TextInput id="username" value={username} onInput={setUsername} required />
|
error={usernameError}
|
||||||
</div>
|
ref={userNameInputRef}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<FormInput
|
||||||
<label htmlFor="email" className="text-sm text-gray-600">
|
id="email"
|
||||||
email (optional)
|
value={email}
|
||||||
</label>
|
onInput={setEmail}
|
||||||
<TextInput id="email" value={email} onInput={setEmail} required />
|
error={emailError}
|
||||||
</div>
|
ref={emailInputRef}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<FormInput
|
||||||
<label htmlFor="password" className="text-sm text-gray-600">
|
|
||||||
password
|
|
||||||
</label>
|
|
||||||
<TextInput
|
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onInput={setPassword}
|
onInput={setPassword}
|
||||||
required
|
error={passwordError}
|
||||||
|
type="password"
|
||||||
|
ref={passwordInputRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
<PrimaryButton
|
||||||
|
className="mt-4"
|
||||||
<PrimaryButton className="mt-4" disabled={isSubmitting} type="submit">
|
disabled={isSubmitting || !!usernameError || !!passwordError}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
{isSubmitting ? 'wait...' : 'give me an account pls'}
|
{isSubmitting ? 'wait...' : 'give me an account pls'}
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
</form>
|
</form>
|
||||||
|
@ -109,3 +138,87 @@ export default function SignupPage() {
|
||||||
</SingleColumnLayout>
|
</SingleColumnLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormInputProps {
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
onInput: (value: string) => void
|
||||||
|
error: string | null
|
||||||
|
type?: 'text' | 'password'
|
||||||
|
ref: Ref<HTMLInputElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormInput({ id, value, onInput, error, type = 'text', ref }: FormInputProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label htmlFor={id} className="text-sm text-gray-600">
|
||||||
|
{id}
|
||||||
|
</label>
|
||||||
|
<TextInput ref={ref} type={type} id={id} value={value} onInput={onInput} />
|
||||||
|
<span className="text-xs h-3 text-red-500">{error}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseValidateInputReturn = [string, (value: string) => void, string | null, () => boolean]
|
||||||
|
|
||||||
|
function useValidatedInput(validator: (value: string) => Validation): UseValidateInputReturn {
|
||||||
|
const [value, setValue] = useState<string>('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [keepValidating, setKeepValidating] = useState(false)
|
||||||
|
|
||||||
|
const validate = useCallback(() => {
|
||||||
|
const { isValid, error } = validator(value)
|
||||||
|
if (isValid) {
|
||||||
|
setError(null)
|
||||||
|
} else {
|
||||||
|
// We only want to validate on input after they have invalidly submitted once.
|
||||||
|
// It's annoying if we set error messages before they've even finished typing.
|
||||||
|
setKeepValidating(true)
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}, [validator, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keepValidating) {
|
||||||
|
validate()
|
||||||
|
}
|
||||||
|
}, [keepValidating, validate])
|
||||||
|
|
||||||
|
return [value, setValue, error, validate]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidUsername(username: string): Validation {
|
||||||
|
if (!username) return invalid('you need to enter a username :/')
|
||||||
|
|
||||||
|
if (username.length < 3) {
|
||||||
|
return invalid('not long enough :(')
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameRegex = /^[a-zA-Z0-9_-]+$/
|
||||||
|
if (usernameRegex.test(username)) {
|
||||||
|
return valid()
|
||||||
|
} else {
|
||||||
|
return invalid("that's not a good username :'(")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEmail(email: string): Validation {
|
||||||
|
if (!email) return valid()
|
||||||
|
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||||||
|
if (emailRegex.test(email)) {
|
||||||
|
return valid()
|
||||||
|
} else {
|
||||||
|
return invalid("um sorry but that doesn't look like an email 🤔")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPassword(password: string): Validation {
|
||||||
|
if (password.length >= 10) {
|
||||||
|
return valid()
|
||||||
|
} else {
|
||||||
|
return invalid("that isn't a good password :/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import { createStore } from './store.ts'
|
import { createStore } from './store.ts'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
userId: string
|
userId: string
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user: User | null = localStorage.getItem('user')
|
||||||
|
|
||||||
|
export function useUserStore() {
|
||||||
|
const [user, setUser] = useState<User | null>(user)
|
||||||
|
}
|
||||||
|
|
||||||
// todo not hardcode
|
// todo not hardcode
|
||||||
export const userStore = createStore<User | null>({
|
export const userStore = createStore<User | null>({
|
||||||
userId: '0196960c-6296-7532-ba66-8fabb38c6ae0',
|
userId: '0196960c-6296-7532-ba66-8fabb38c6ae0',
|
||||||
|
|
19
src/utils/validation.ts
Normal file
19
src/utils/validation.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export type Validation = Valid | Invalid
|
||||||
|
|
||||||
|
interface Valid {
|
||||||
|
isValid: true
|
||||||
|
error: null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Invalid {
|
||||||
|
isValid: false
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function valid(): Validation {
|
||||||
|
return { isValid: true, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalid(error: string): Validation {
|
||||||
|
return { isValid: false, error }
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue