mirror of
https://github.com/fergalmoran/opengifame.git
synced 2025-12-22 09:38:44 +00:00
Clean up upload form
This commit is contained in:
8519
package-lock.json
generated
8519
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.4.2",
|
||||
"@headlessui/react": "^2.1.5",
|
||||
"@headlessui/react": "^2.1.6",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -47,7 +47,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tanstack/react-query": "^5.55.1",
|
||||
"@tanstack/react-query": "^5.55.4",
|
||||
"@trpc/client": "^11.0.0-rc.446",
|
||||
"@trpc/react-query": "^11.0.0-rc.446",
|
||||
"@trpc/server": "^11.0.0-rc.446",
|
||||
@@ -69,7 +69,7 @@
|
||||
"loglevel": "^1.9.2",
|
||||
"loglevel-plugin-prefix": "^0.8.4",
|
||||
"lucide-react": "^0.439.0",
|
||||
"next": "^14.2.8",
|
||||
"next": "^14.2.9",
|
||||
"next-auth": "^4.24.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"postgres": "^3.4.4",
|
||||
@@ -91,21 +91,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.0",
|
||||
"@types/eslint": "^8.56.12",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.4.0",
|
||||
"@typescript-eslint/parser": "^8.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.5.0",
|
||||
"@typescript-eslint/parser": "^8.5.0",
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.8",
|
||||
"eslint": "^9.10.0",
|
||||
"eslint-config-next": "^14.2.9",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"postcss": "^8.4.45",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.37.0"
|
||||
|
||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
@@ -8,13 +8,12 @@ interface AuthLayoutProps {
|
||||
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="container flex h-screen w-screen flex-col items-center justify-center">
|
||||
<div className="container flex h-screen w-screen flex-col items-center ">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"absolute left-4 top-4 md:left-8 md:top-8",
|
||||
"absolute left-4 top-4 mt-8 md:left-8 md:top-8",
|
||||
)}
|
||||
>
|
||||
<>
|
||||
@@ -22,9 +21,7 @@ export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
Back
|
||||
</>
|
||||
</Link>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/app/(site)/upload/page.tsx
Normal file
5
src/app/(site)/upload/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import UploadPage from "@/components/pages/upload-page";
|
||||
|
||||
const Upload = () => <UploadPage />;
|
||||
|
||||
export default Upload;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Roboto as font } from "next/font/google";
|
||||
import { Inknut_Antiqua as font } from "next/font/google";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
@@ -13,6 +13,7 @@ import TopNavbar from "@/components/navbar/top-navbar";
|
||||
import { dashboardConfig } from "@/config/top-nav.config";
|
||||
import { siteConfig } from "@/config/site.config";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
@@ -21,8 +22,8 @@ export const viewport: Viewport = {
|
||||
],
|
||||
};
|
||||
const f = font({
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
weight: ["400"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
@@ -49,6 +49,7 @@ const SignInForm: React.FC = () => {
|
||||
logger.debug("signin", "result", result);
|
||||
if (result?.status === 200) {
|
||||
router.push("/");
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("SignInForm", "error", error);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type Icon as LucideIcon,
|
||||
Terminal,
|
||||
LogIn,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
export type Icon = typeof LucideIcon;
|
||||
@@ -112,4 +113,5 @@ export const Icons = {
|
||||
),
|
||||
twitter: Twitter,
|
||||
check: Check,
|
||||
upload: Upload,
|
||||
};
|
||||
|
||||
@@ -5,12 +5,12 @@ import React from "react";
|
||||
import { type NavItem } from "@/types";
|
||||
import { type Session } from "next-auth";
|
||||
|
||||
import { Icons } from "../icons";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { siteConfig } from "@/config/site.config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
|
||||
import LoginButton from "../widgets/login/login-button";
|
||||
import LoginButton from "@/components/widgets/login/login-button";
|
||||
|
||||
type TopNavbarProps = {
|
||||
items: NavItem[];
|
||||
|
||||
137
src/components/pages/upload-page.tsx
Normal file
137
src/components/pages/upload-page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Form, FormProvider, useForm } from "react-hook-form";
|
||||
import { SubmitHandler, Controller } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ImageUpload from "@/components/widgets/image-upload";
|
||||
import TaggedInput from "@/components/widgets/tagged-input";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type FormValues = {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
image: string | undefined;
|
||||
};
|
||||
|
||||
const UploadPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
tags: [],
|
||||
image: undefined,
|
||||
},
|
||||
});
|
||||
const _submit: SubmitHandler<FormValues> = async (data) => {
|
||||
console.log(data);
|
||||
if (data.image) {
|
||||
const body = new FormData();
|
||||
body.append("title", data.title);
|
||||
body.append("description", data.description);
|
||||
body.append("tags", data.tags.join("|"));
|
||||
body.append("file", data.image);
|
||||
const response = await fetch("api/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (response.status === 201) {
|
||||
await router.replace("/");
|
||||
}
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="md:grid md:grid-cols-3 md:gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<div className="px-4 sm:px-0">
|
||||
<h3 className="text-lg font-extrabold leading-6">Upload a new gif</h3>
|
||||
<p className="text-base-content/70 my-3 text-sm">
|
||||
The more info you can give us the better.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(_submit)}>
|
||||
<div className="shadow sm:overflow-hidden sm:rounded-md">
|
||||
<div className="space-y-4 px-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
defaultValue={"fergal.moran+opengifame@gmail.com"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
{form.formState.errors.title && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.title.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
defaultValue={"fergal.moran+opengifame@gmail.com"}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} rows={3} />
|
||||
</FormControl>
|
||||
{form.formState.errors.description && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.description.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="image"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ImageUpload value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
<div className="divider pt-4">optional stuff</div>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TaggedInput
|
||||
label="Tags"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 text-right">
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
Upload Gif
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/trpc/react";
|
||||
import ImageUpload from "./widgets/image-upload";
|
||||
|
||||
export function TrendingImages() {
|
||||
const [latestPost] = api.post.getLatest.useSuspenseQuery();
|
||||
@@ -23,21 +24,13 @@ export function TrendingImages() {
|
||||
) : (
|
||||
<p>No images yet.</p>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createPost.mutate({ name });
|
||||
|
||||
<ImageUpload
|
||||
value="Farts"
|
||||
onChange={(e) => {
|
||||
console.log("trending-images", "uploading", e);
|
||||
}}
|
||||
className="flex flex-col gap-2 my-4"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
|
||||
disabled={createPost.isPending}
|
||||
>
|
||||
{createPost.isPending ? "Submitting..." : "Upload an image"}
|
||||
</button>
|
||||
</form>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/components/ui/textarea.tsx
Normal file
24
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,61 +1,59 @@
|
||||
// /* eslint-disable @next/next/no-img-element */
|
||||
// import React from "react";
|
||||
// import { HiOutlineXMark } from "react-icons/hi2";
|
||||
// import { ImUpload2 } from "react-icons/im";
|
||||
// interface IImageUploadProps {
|
||||
// value: string | undefined;
|
||||
// onChange: (image: File) => void;
|
||||
// }
|
||||
// const ImageUpload: React.FC<IImageUploadProps> = ({ onChange }) => {
|
||||
// const [image, setImage] = React.useState<string>();
|
||||
// const onImageChange = ($event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// console.log("ImageUpload", "onImageChange", $event);
|
||||
// if ($event.target.files) {
|
||||
// const url = URL.createObjectURL($event.target.files[0]);
|
||||
// setImage(url);
|
||||
// onChange($event.target.files[0]);
|
||||
// }
|
||||
// };
|
||||
// return (
|
||||
// <div className="mt-1 flex justify-center rounded-md border-2 border-dashed border-secondary px-6 pb-6 pt-5">
|
||||
// {image ? (
|
||||
// <div>
|
||||
// <div className="relative">
|
||||
// <img src={image} alt="Preview" />
|
||||
// <button
|
||||
// type="button"
|
||||
// className="absolute right-0 top-0 m-2 rounded-full p-2"
|
||||
// onClick={() => setImage("")}
|
||||
// >
|
||||
// {""}
|
||||
// <HiOutlineXMark className="h-5 w-5" />
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// ) : (
|
||||
// <div className="space-y-1 text-center">
|
||||
// <ImUpload2 className="text-base-content/70 mx-auto mb-3 h-8 w-8" />
|
||||
// <div className="text-base-content flex text-sm">
|
||||
// <label
|
||||
// htmlFor="gif-upload"
|
||||
// className="badge badge-primary relative cursor-pointer rounded-md font-medium hover:text-accent"
|
||||
// >
|
||||
// <span>Upload a file</span>
|
||||
// <input
|
||||
// accept="image/gif,video/mp4,video/mov,video/quicktime,video/webm,youtube,vimeo"
|
||||
// id="gif-upload"
|
||||
// type="file"
|
||||
// className="sr-only"
|
||||
// onChange={onImageChange}
|
||||
// />
|
||||
// </label>
|
||||
// <p className="pl-1">or drag and drop</p>
|
||||
// </div>
|
||||
// <p className="text-xs">PNG, JPG, GIF up to 10MB</p>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React from "react";
|
||||
import { Icons } from "../icons";
|
||||
interface IImageUploadProps {
|
||||
value: string | undefined;
|
||||
onChange: (image: File) => void;
|
||||
}
|
||||
const ImageUpload: React.FC<IImageUploadProps> = ({ onChange }) => {
|
||||
const [image, setImage] = React.useState<string>();
|
||||
const onImageChange = ($event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
console.log("ImageUpload", "onImageChange", $event);
|
||||
if ($event.target.files?.[0]) {
|
||||
const url = URL.createObjectURL($event.target.files[0]);
|
||||
setImage(url);
|
||||
onChange($event.target.files[0]);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mt-1 flex justify-center rounded-md border-2 border-dashed border-secondary px-6 pb-6 pt-5">
|
||||
{image ? (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<img src={image} alt="Preview" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 m-2 rounded-full p-2"
|
||||
onClick={() => setImage("")}
|
||||
>
|
||||
<Icons.close className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 text-center">
|
||||
<Icons.upload className="text-base-content/70 mx-auto mb-3 h-8 w-8" />
|
||||
<div className="text-base-content flex text-sm">
|
||||
<label
|
||||
htmlFor="gif-upload"
|
||||
className="badge badge-primary relative cursor-pointer rounded-md font-medium hover:text-accent"
|
||||
>
|
||||
<span>Upload a file</span>
|
||||
<input
|
||||
accept="image/gif,video/mp4,video/mov,video/quicktime,video/webm,youtube,vimeo"
|
||||
id="gif-upload"
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={onImageChange}
|
||||
/>
|
||||
</label>
|
||||
<p className="pl-1">or drag and drop</p>
|
||||
</div>
|
||||
<p className="text-xs">PNG, JPG, GIF up to 10MB</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// export default ImageUpload;
|
||||
export default ImageUpload;
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { RiLoginCircleLine } from "react-icons/ri";
|
||||
import UserNavDropdown from "../user-nav-dropdown";
|
||||
import UserNavDropdown from "@/components/widgets/user-nav-dropdown";
|
||||
import { type Session } from "next-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Icons } from "@/components/icons";
|
||||
|
||||
@@ -1,143 +1,143 @@
|
||||
// import { logger } from "@/lib/logger";
|
||||
// import React, { KeyboardEventHandler } from "react";
|
||||
import { logger } from "@/lib/logger";
|
||||
import React, { KeyboardEventHandler } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
// interface ITaggedInputProps {
|
||||
// label: string;
|
||||
// value: string[];
|
||||
// onChange: (tags: string[]) => void;
|
||||
// }
|
||||
// const TaggedInput: React.FC<ITaggedInputProps> = ({
|
||||
// label,
|
||||
// value,
|
||||
// onChange,
|
||||
// }) => {
|
||||
// const [isSearching, setIsSearching] = React.useState(false);
|
||||
// const [searchText, setSearchText] = React.useState<string>("");
|
||||
// const [searchResults, setSearchResults] = React.useState<Array<string>>([]);
|
||||
// const [tags, setTags] = React.useState<Array<string>>(value);
|
||||
interface ITaggedInputProps {
|
||||
label: string;
|
||||
value: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
const TaggedInput: React.FC<ITaggedInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
const [searchText, setSearchText] = React.useState<string>("");
|
||||
const [searchResults, setSearchResults] = React.useState<Array<string>>([]);
|
||||
const [tags, setTags] = React.useState<Array<string>>(value);
|
||||
|
||||
// React.useEffect(() => {
|
||||
// logger.debug("TaggedInput", "callingOnChange", tags);
|
||||
// onChange(tags);
|
||||
// }, [tags, onChange]);
|
||||
// const removeTag = (tag: string) => {
|
||||
// setTags(tags.filter((obj) => obj !== tag));
|
||||
// };
|
||||
// const searchTags = async (query: string) => {
|
||||
// if (!query) {
|
||||
// setSearchResults([]);
|
||||
// return;
|
||||
// }
|
||||
// setIsSearching(true);
|
||||
// const response = await fetch(`api/tags/search?q=${searchText}`);
|
||||
// if (response.status === 200) {
|
||||
// const results = await response.json();
|
||||
// setSearchResults(results.map((r: { name: string }) => r.name));
|
||||
// }
|
||||
// setIsSearching(false);
|
||||
// };
|
||||
// const handleChange = async ($event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// const { value } = $event.target;
|
||||
// setSearchText(value);
|
||||
// await searchTags(value);
|
||||
// };
|
||||
// const handleKeyPress = ($event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// if ($event.code === "Enter" || $event.code === "NumpadEnter") {
|
||||
// __addTag(searchText);
|
||||
// }
|
||||
// };
|
||||
// const __addTag = (tag: string) => {
|
||||
// setTags([...tags, tag]);
|
||||
// setSearchResults([]);
|
||||
// setIsSearching(false);
|
||||
// setSearchText("");
|
||||
// };
|
||||
// const doResultClick = ($event: any) =>
|
||||
// __addTag($event.target.textContent as string);
|
||||
// return (
|
||||
// <>
|
||||
// <label htmlFor="{name}" className="block text-sm font-medium">
|
||||
// {label}
|
||||
// </label>
|
||||
// <div className="border-accent flex w-full rounded-lg border align-middle text-sm shadow-sm">
|
||||
// <div className="flex flex-row space-x-1 pl-2 pt-3">
|
||||
// {tags &&
|
||||
// tags.map((tag) => (
|
||||
// <span key={tag} className="badge badge-primary badge-lg py-0.5">
|
||||
// {tag}
|
||||
// <button
|
||||
// onClick={() => removeTag(tag)}
|
||||
// type="button"
|
||||
// className="ml-0.5 inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full"
|
||||
// >
|
||||
// <span className="sr-only">{tag}</span>
|
||||
// <svg
|
||||
// className="h-2 w-2"
|
||||
// stroke="currentColor"
|
||||
// fill="none"
|
||||
// viewBox="0 0 8 8"
|
||||
// >
|
||||
// <path
|
||||
// strokeLinecap="round"
|
||||
// strokeWidth={1.5}
|
||||
// d="M1 1l6 6m0-6L1 7"
|
||||
// />
|
||||
// </svg>
|
||||
// </button>
|
||||
// </span>
|
||||
// ))}
|
||||
// </div>
|
||||
// <input
|
||||
// value={searchText}
|
||||
// onKeyDown={handleKeyPress}
|
||||
// onChange={handleChange}
|
||||
// placeholder="Start typing and press enter"
|
||||
// className="input w-full focus:outline-none"
|
||||
// />
|
||||
// </div>
|
||||
// {isSearching && (
|
||||
// <div role="status" className="z-50 -mt-3 ml-5">
|
||||
// <svg
|
||||
// aria-hidden="true"
|
||||
// className="mr-2 h-4 w-4"
|
||||
// viewBox="0 0 100 101"
|
||||
// fill="none"
|
||||
// xmlns="http://www.w3.org/2000/svg"
|
||||
// >
|
||||
// <path
|
||||
// d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
// fill="currentColor"
|
||||
// />
|
||||
// <path
|
||||
// d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
// fill="currentFill"
|
||||
// />
|
||||
// </svg>
|
||||
// <span className="sr-only">Loading...</span>
|
||||
// </div>
|
||||
// )}
|
||||
// {searchResults && searchResults.length !== 0 && (
|
||||
// <div className={`z-50 mb-4 flex space-y-0`}>
|
||||
// <aside
|
||||
// aria-labelledby="menu-heading"
|
||||
// className="absolute z-50 -mt-5 flex w-64 flex-col items-start rounded-md border bg-white text-sm shadow-md"
|
||||
// >
|
||||
// <ul className="flex w-full flex-col">
|
||||
// {searchResults.map((result) => (
|
||||
// <li
|
||||
// key={result}
|
||||
// onClick={doResultClick}
|
||||
// className="cursor-pointer space-x-2 px-2 py-1 hover:bg-indigo-500 hover:text-white focus:bg-indigo-500 focus:text-white focus:outline-none"
|
||||
// >
|
||||
// {result}
|
||||
// </li>
|
||||
// ))}
|
||||
// </ul>
|
||||
// </aside>
|
||||
// </div>
|
||||
// )}
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
React.useEffect(() => {
|
||||
logger.debug("TaggedInput", "callingOnChange", tags);
|
||||
onChange(tags);
|
||||
}, [tags, onChange]);
|
||||
const removeTag = (tag: string) => {
|
||||
setTags(tags.filter((obj) => obj !== tag));
|
||||
};
|
||||
const searchTags = async (query: string) => {
|
||||
if (!query) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
const response = await fetch(`api/tags/search?q=${searchText}`);
|
||||
if (response.status === 200) {
|
||||
const results = await response.json();
|
||||
setSearchResults(results.map((r: { name: string }) => r.name));
|
||||
}
|
||||
setIsSearching(false);
|
||||
};
|
||||
const handleChange = async ($event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = $event.target;
|
||||
setSearchText(value);
|
||||
await searchTags(value);
|
||||
};
|
||||
const handleKeyPress = ($event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ($event.code === "Enter" || $event.code === "NumpadEnter") {
|
||||
__addTag(searchText);
|
||||
}
|
||||
};
|
||||
const __addTag = (tag: string) => {
|
||||
setTags([...tags, tag]);
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
setSearchText("");
|
||||
};
|
||||
const doResultClick = ($event: any) =>
|
||||
__addTag($event.target.textContent as string);
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="{name}" className="block text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex w-full rounded-lg align-middle text-sm shadow-sm">
|
||||
<div className="flex flex-row space-x-1">
|
||||
{tags &&
|
||||
tags.map((tag) => (
|
||||
<span key={tag} className="badge badge-primary badge-lg py-0.5">
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => removeTag(tag)}
|
||||
type="button"
|
||||
className="ml-0.5 inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<span className="sr-only">{tag}</span>
|
||||
<svg
|
||||
className="h-2 w-2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 8 8"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeWidth={1.5}
|
||||
d="M1 1l6 6m0-6L1 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
value={searchText}
|
||||
onKeyDown={handleKeyPress}
|
||||
onChange={handleChange}
|
||||
placeholder="Start typing and press enter"
|
||||
/>
|
||||
</div>
|
||||
{isSearching && (
|
||||
<div role="status" className="z-50 -mt-3 ml-5">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-4 w-4"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
{searchResults && searchResults.length !== 0 && (
|
||||
<div className={`z-50 mb-4 flex space-y-0`}>
|
||||
<aside
|
||||
aria-labelledby="menu-heading"
|
||||
className="absolute z-50 -mt-5 flex w-64 flex-col items-start rounded-md border bg-white text-sm shadow-md"
|
||||
>
|
||||
<ul className="flex w-full flex-col">
|
||||
{searchResults.map((result) => (
|
||||
<li
|
||||
key={result}
|
||||
onClick={doResultClick}
|
||||
className="cursor-pointer space-x-2 px-2 py-1 hover:bg-indigo-500 hover:text-white focus:bg-indigo-500 focus:text-white focus:outline-none"
|
||||
>
|
||||
{result}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// export default TaggedInput;
|
||||
export default TaggedInput;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
import React, { Fragment } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -9,10 +11,11 @@ interface IUserNavDropdownProps {
|
||||
session: Session | null;
|
||||
}
|
||||
const UserNavDropdown: React.FC<IUserNavDropdownProps> = ({ session }) => {
|
||||
const [profileImage, setProfileImage] = React.useState<string>();
|
||||
const [profileImage, setProfileImage] = React.useState<string>(
|
||||
session?.user?.image || "/images/default-profile.jpg",
|
||||
);
|
||||
React.useEffect(() => {
|
||||
logger.debug("UserNavDropdown", "session", session);
|
||||
|
||||
setProfileImage(session?.user?.image || "/images/default-profile.jpg");
|
||||
}, [session]);
|
||||
|
||||
@@ -22,11 +25,15 @@ const UserNavDropdown: React.FC<IUserNavDropdownProps> = ({ session }) => {
|
||||
<div>
|
||||
<Menu.Button className="flex rounded-full bg-gray-800 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
{profileImage && (
|
||||
<Image
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full"
|
||||
src={profileImage}
|
||||
alt="Profile image"
|
||||
/>
|
||||
)}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
|
||||
@@ -72,5 +72,7 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||
function getBaseUrl() {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
|
||||
if (process.env.NEXT_PUBLIC_SITE_URL)
|
||||
return `https://${process.env.NEXT_PUBLIC_SITE_URL}`;
|
||||
return `http://localhost:${process.env.PORT ?? 3000}`;
|
||||
}
|
||||
|
||||
@@ -1,84 +1,80 @@
|
||||
import { type Config } from "tailwindcss";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.tsx"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-geist-sans)", ...fontFamily.sans]
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
"accordion-down": {
|
||||
from: {
|
||||
height: '0'
|
||||
height: "0",
|
||||
},
|
||||
to: {
|
||||
height: 'var(--radix-accordion-content-height)'
|
||||
}
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
'accordion-up': {
|
||||
},
|
||||
"accordion-up": {
|
||||
from: {
|
||||
height: 'var(--radix-accordion-content-height)'
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
to: {
|
||||
height: '0'
|
||||
}
|
||||
}
|
||||
height: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out'
|
||||
}
|
||||
}
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
|
||||
Reference in New Issue
Block a user