pancy fants
This commit is contained in:
parent
a4fd3a3556
commit
b6633d6f25
15 changed files with 339 additions and 116 deletions
|
@ -18,8 +18,8 @@ export default function FancyTextEditor({
|
|||
value: _value,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
className,
|
||||
placeholder,
|
||||
className: extraClasses = '',
|
||||
placeholder = '',
|
||||
}: TextInputProps) {
|
||||
const divRef = useRef<HTMLDivElement>(null)
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
|
@ -39,7 +39,7 @@ export default function FancyTextEditor({
|
|||
}
|
||||
|
||||
if (!value && !hasFocus) {
|
||||
div.innerText = placeholder ?? ''
|
||||
div.innerText = placeholder
|
||||
} else if (div.innerText !== value) {
|
||||
div.innerText = value
|
||||
}
|
||||
|
@ -58,7 +58,7 @@ export default function FancyTextEditor({
|
|||
const blurListener = () => {
|
||||
setHasFocus(false)
|
||||
if (!value) {
|
||||
div.innerText = placeholder ?? ''
|
||||
div.innerText = placeholder
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -86,7 +86,7 @@ export default function FancyTextEditor({
|
|||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
className={`w-full p-3 resize-none border border-gray-200 rounded-md focus:outline-none focus:border-gray-300 ${textColour} min-h-30 ${className ?? ''}`}
|
||||
className={`text-input w-full min-h-30 ${textColour} ${extraClasses}`}
|
||||
contentEditable
|
||||
onKeyDown={handleKeyDown}
|
||||
suppressContentEditableWarning
|
||||
|
|
11
src/components/NavBar.tsx
Normal file
11
src/components/NavBar.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Link } from 'react-router'
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<nav className={`w-full flex flex-row-reverse px-4 md:px-8 py-0.5`}>
|
||||
<Link className={`text-gray-800`} to="/signup">
|
||||
create account
|
||||
</Link>
|
||||
</nav>
|
||||
)
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
import { useState, ChangeEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import FancyTextEditor, { TextInputKeyDownEvent } from './FancyTextEditor.tsx'
|
||||
import PrimaryButton from './PrimaryButton.tsx'
|
||||
import SecondaryButton from './SecondaryButton.tsx'
|
||||
import { openFileDialog } from '../utils/openFileDialog.ts'
|
||||
|
||||
interface NewPostWidgetProps {
|
||||
onSubmit: (content: string, media: File[]) => void
|
||||
|
@ -20,20 +23,19 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
|||
setContent(value)
|
||||
}
|
||||
|
||||
const handleMediaChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const inputEl = e.target as HTMLInputElement
|
||||
if (inputEl.files == null || inputEl.files.length === 0) {
|
||||
async function onAddMediaClicked() {
|
||||
const files = await openFileDialog('image/*', true)
|
||||
if (files == null || files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newFiles = Array.from(inputEl.files).map((file) => ({
|
||||
const newFiles = Array.from(files).map((file) => ({
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
objectUrl: URL.createObjectURL(file),
|
||||
}))
|
||||
|
||||
setAttachments((attachments) => [...attachments, ...newFiles])
|
||||
inputEl.value = ''
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
|
@ -90,24 +92,13 @@ export default function NewPostWidget({ onSubmit, isSubmitting = false }: NewPos
|
|||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="cursor-pointer text-gray-500 hover:text-gray-700">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleMediaChange}
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<span className="flex items-center">+ Add media</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-700 disabled:opacity-50"
|
||||
<SecondaryButton onClick={onAddMediaClicked}>+ add media</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || (content.trim() === '' && attachments.length === 0)}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
post
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
27
src/components/PrimaryButton.tsx
Normal file
27
src/components/PrimaryButton.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
disabled?: boolean
|
||||
type?: 'submit' | 'button'
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PrimaryButton({
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
onClick = () => {},
|
||||
className: extraClasses = '',
|
||||
children,
|
||||
}: PropsWithChildren<PrimaryButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`primary-button ${extraClasses}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
18
src/components/PrimaryLinkButton.tsx
Normal file
18
src/components/PrimaryLinkButton.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface PrimaryLinkButtonProps {
|
||||
href: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PrimaryLinkButton({
|
||||
href,
|
||||
className: extraClasses = '',
|
||||
children,
|
||||
}: PropsWithChildren<PrimaryLinkButtonProps>) {
|
||||
return (
|
||||
<a href={href} className={`primary-button text-center ${extraClasses}`}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
31
src/components/SecondaryButton.tsx
Normal file
31
src/components/SecondaryButton.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
interface PrimaryButtonProps {
|
||||
disabled?: boolean
|
||||
type?: 'submit' | 'button'
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SecondaryButton({
|
||||
disabled = false,
|
||||
type = 'button',
|
||||
onClick = () => {},
|
||||
className: extraClasses = '',
|
||||
children,
|
||||
}: PropsWithChildren<PrimaryButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
px-4 p-2 rounded-md
|
||||
text-primary-500 hover:text-primary-700
|
||||
cursor-pointer disabled:cursor-default
|
||||
${extraClasses}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
31
src/components/TextInput.tsx
Normal file
31
src/components/TextInput.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
interface TextInputProps {
|
||||
id?: string
|
||||
type?: 'text' | 'email' | 'password'
|
||||
value: string
|
||||
onInput: (value: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export default function TextInput({
|
||||
id,
|
||||
value,
|
||||
onInput,
|
||||
className: extraClasses = '',
|
||||
placeholder = '',
|
||||
type = 'text',
|
||||
required = false,
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
value={value}
|
||||
type={type}
|
||||
required={required}
|
||||
onChange={(e) => onInput(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`text-input w-full ${extraClasses}`}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -28,5 +28,5 @@ export function useFeedViewModel(
|
|||
}
|
||||
}, [loadMore, hasMore])
|
||||
|
||||
return [pages, setPages, loadNextPage] as const
|
||||
return { pages, setPages, loadNextPage } as const
|
||||
}
|
|
@ -6,4 +6,66 @@
|
|||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@layer variables {
|
||||
:root {
|
||||
/* TODO find out if tailwind exposes these spacing variables already */
|
||||
--spacing-2: calc(var(--spacing) * 2);
|
||||
--spacing-4: calc(var(--spacing) * 4);
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-primary-50: var(--color-gray-50);
|
||||
--color-primary-100: var(--color-gray-100);
|
||||
--color-primary-200: var(--color-gray-200);
|
||||
--color-primary-300: var(--color-gray-300);
|
||||
--color-primary-400: var(--color-gray-400);
|
||||
--color-primary-500: var(--color-gray-500);
|
||||
--color-primary-600: var(--color-gray-600);
|
||||
--color-primary-700: var(--color-gray-700);
|
||||
--color-primary-800: var(--color-gray-800);
|
||||
--color-primary-900: var(--color-gray-900);
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
component class for text-inputs
|
||||
as of writing this we want to reuse this for FancyTextEditor and TextInput
|
||||
which is shitty and difficult to do by creating some react component with tailwind
|
||||
it might be they need to diverge in the future, in which case i suppose we can
|
||||
split this up, and potentially inline the tailwind classes
|
||||
*/
|
||||
.text-input {
|
||||
padding: var(--spacing-2);
|
||||
resize: none;
|
||||
border: 1px solid var(--color-gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-gray-300);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
background: var(--color-primary-800);
|
||||
color: var(--color-white);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.primary-button:hover {
|
||||
background: var(--color-primary-700);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
opacity: 50%;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
export default function AppLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div>
|
||||
<nav className={`w-full flex flex-row-reverse px-4 md:px-8 py-0.5`}>
|
||||
<Link className={`text-gray-800`} to="/signup">
|
||||
create account
|
||||
</Link>
|
||||
</nav>
|
||||
<main className={`w-full`}>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
17
src/layouts/SingleColumnLayout.tsx
Normal file
17
src/layouts/SingleColumnLayout.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { PropsWithChildren, ReactNode } from 'react'
|
||||
|
||||
interface SingleColumnLayoutProps {
|
||||
navbar?: ReactNode
|
||||
}
|
||||
|
||||
export default function SingleColumnLayout({
|
||||
children,
|
||||
navbar,
|
||||
}: PropsWithChildren<SingleColumnLayoutProps>) {
|
||||
return (
|
||||
<div>
|
||||
{navbar}
|
||||
<main className={`w-full max-w-3xl mx-auto`}>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -2,6 +2,9 @@ import { useCallback } from 'react'
|
|||
import FeedView from '../feed/FeedView.tsx'
|
||||
import { PostsService } from '../model/posts/postsService.ts'
|
||||
import { useParams } from 'react-router'
|
||||
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||
import NavBar from '../components/NavBar.tsx'
|
||||
import { useFeedViewModel } from '../feed/FeedView.ts'
|
||||
|
||||
interface AuthorPageParams {
|
||||
postsService: PostsService
|
||||
|
@ -17,5 +20,11 @@ export default function AuthorPage({ postsService }: AuthorPageParams) {
|
|||
[postsService, username],
|
||||
)
|
||||
|
||||
return <FeedView loadMore={fetchPosts} />
|
||||
const { pages, loadNextPage } = useFeedViewModel(fetchPosts)
|
||||
|
||||
return (
|
||||
<SingleColumnLayout navbar={<NavBar />}>
|
||||
<FeedView pages={pages} onLoadMore={loadNextPage} />
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,10 +4,11 @@ import { PostsService } from '../model/posts/postsService.ts'
|
|||
import { useUserStore } from '../store/userStore.ts'
|
||||
import { MediaService } from '../model/mediaService.ts'
|
||||
import NewPostWidget from '../components/NewPostWidget.tsx'
|
||||
import { useFeedViewModel } from '../feed/feedViewModel.ts'
|
||||
import { useFeedViewModel } from '../feed/FeedView.ts'
|
||||
import { Post } from '../model/posts/posts.ts'
|
||||
import { Temporal } from '@js-temporal/polyfill'
|
||||
import AppLayout from '../layouts/AppLayout.tsx'
|
||||
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||
import NavBar from '../components/NavBar.tsx'
|
||||
|
||||
interface HomePageProps {
|
||||
postsService: PostsService
|
||||
|
@ -26,7 +27,7 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
|||
[postsService],
|
||||
)
|
||||
|
||||
const [pages, setPages, loadNextPage] = useFeedViewModel(fetchPosts)
|
||||
const { pages, setPages, loadNextPage } = useFeedViewModel(fetchPosts)
|
||||
|
||||
const onCreatePost = useCallback(
|
||||
async (content: string, files: File[]) => {
|
||||
|
@ -49,11 +50,11 @@ export default function HomePage({ postsService, mediaService }: HomePageProps)
|
|||
)
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<SingleColumnLayout navbar={<NavBar />}>
|
||||
<main className={`w-full max-w-3xl mx-auto`}>
|
||||
<NewPostWidget onSubmit={onCreatePost} isSubmitting={isSubmitting} />
|
||||
<FeedView pages={pages} onLoadMore={loadNextPage} />
|
||||
</main>
|
||||
</AppLayout>
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,85 +1,111 @@
|
|||
import { useParams } from 'react-router'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import SingleColumnLayout from '../layouts/SingleColumnLayout.tsx'
|
||||
import TextInput from '../components/TextInput.tsx'
|
||||
import PrimaryButton from '../components/PrimaryButton.tsx'
|
||||
import PrimaryLinkButton from '../components/PrimaryLinkButton.tsx'
|
||||
|
||||
const SignupCodeKey = 'signupCode'
|
||||
|
||||
export default function SignupPage() {
|
||||
const { code: signupCode } = useParams()
|
||||
const { code } = useParams()
|
||||
const [signupCode, setSignupCode] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (signupCode) return
|
||||
|
||||
let theSignupCode: string | null
|
||||
if (code) {
|
||||
theSignupCode = code
|
||||
setSignupCode(theSignupCode)
|
||||
localStorage.setItem(SignupCodeKey, theSignupCode)
|
||||
} else {
|
||||
theSignupCode = localStorage.getItem(SignupCodeKey)
|
||||
}
|
||||
|
||||
if (!theSignupCode) {
|
||||
dialogRef.current?.showModal()
|
||||
}
|
||||
}, [code, signupCode])
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
}
|
||||
|
||||
if (!signupCode) {
|
||||
return <RejectionMessage />
|
||||
try {
|
||||
// todo
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="w-full max-w-3xl 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-2">
|
||||
<label htmlFor="username" className="text-sm text-gray-600">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className="p-2 border rounded bg-white/50 focus:outline-none focus:border-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<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-2">
|
||||
<label htmlFor="username" className="text-sm text-gray-600">
|
||||
username
|
||||
</label>
|
||||
<TextInput id="username" value={username} onInput={setUsername} required />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="email" className="text-sm text-gray-600">
|
||||
Email (optional)
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="p-2 border rounded bg-white/50 focus:outline-none focus:border-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="email" className="text-sm text-gray-600">
|
||||
email (optional)
|
||||
</label>
|
||||
<TextInput id="email" value={email} onInput={setEmail} required />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="password" className="text-sm text-gray-600">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="p-2 border rounded bg-white/50 focus:outline-none focus:border-gray-400"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="password" className="text-sm text-gray-600">
|
||||
password
|
||||
</label>
|
||||
<TextInput
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onInput={setPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="mt-4 p-2 bg-gray-800 text-white rounded hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function RejectionMessage() {
|
||||
return (
|
||||
<main className="w-full max-w-3xl mx-auto p-4">
|
||||
<div className="mt-12 text-gray-600 flex flex-col gap-2">
|
||||
<p>An invitation is required to create an account.</p>
|
||||
<p>
|
||||
I'm surprised you even found your way here without one and honestly I'd prefer it if you
|
||||
would leave
|
||||
</p>
|
||||
<p>
|
||||
If you <span className="italic">do</span> want to create an account, you should know who
|
||||
to contact
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<PrimaryButton className="mt-4" disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? 'wait...' : 'give me an account pls'}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<dialog
|
||||
id="go-away-dialog"
|
||||
ref={dialogRef}
|
||||
className="p-6 rounded-lg shadow-lg m-auto outline-none"
|
||||
>
|
||||
<div className="text-gray-600 flex flex-col gap-2">
|
||||
<h1 className={`font-bold text-lg`}>STOP !!!</h1>
|
||||
<p>You need an invitation to sign up</p>
|
||||
<p>
|
||||
I'm surprised you even found your way here without one and honestly I'd prefer it if you
|
||||
would leave
|
||||
</p>
|
||||
<p>
|
||||
If you <span className="italic">do</span> want to create an account, you should know who
|
||||
to contact
|
||||
</p>
|
||||
<PrimaryLinkButton className={`mt-4`} href="https://en.wikipedia.org/wiki/Special:Random">
|
||||
I'm sorry I'll go somewhere else :(
|
||||
</PrimaryLinkButton>
|
||||
</div>
|
||||
</dialog>
|
||||
</SingleColumnLayout>
|
||||
)
|
||||
}
|
||||
|
|
14
src/utils/openFileDialog.ts
Normal file
14
src/utils/openFileDialog.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export function openFileDialog(accept: string, multiple: boolean): Promise<FileList | null> {
|
||||
return new Promise((resolve) => {
|
||||
const inputEl = document.createElement('input')
|
||||
inputEl.type = 'file'
|
||||
inputEl.accept = accept
|
||||
inputEl.multiple = multiple
|
||||
|
||||
inputEl.addEventListener('change', () => {
|
||||
resolve(inputEl.files)
|
||||
})
|
||||
|
||||
inputEl.click()
|
||||
})
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue