diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f64575..d35b258 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,6 @@ on: branches: - main -permissions: - contents: read - jobs: release: env: diff --git a/prisma/migrations/20240628063912_add_scafollded_tables/migration.sql b/prisma/migrations/20240628063912_add_scafollded_tables/migration.sql new file mode 100644 index 0000000..d07c3d5 --- /dev/null +++ b/prisma/migrations/20240628063912_add_scafollded_tables/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable +CREATE TABLE "Post" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdById" TEXT NOT NULL, + + CONSTRAINT "Post_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + "refresh_token_expires_in" INTEGER, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" TIMESTAMP(3), + "image" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateIndex +CREATE INDEX "Post_name_idx" ON "Post"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/src/app/_components/connected-user.tsx b/src/app/_components/connected-user.tsx index 5eedcc7..5f5f477 100644 --- a/src/app/_components/connected-user.tsx +++ b/src/app/_components/connected-user.tsx @@ -1,25 +1,31 @@ -import Avatar from "@components/ui/avatar"; -import React from "react"; +import React, { memo } from "react"; +import UserAvatar from "@components/user-avatar"; +import { calculateElapsedTime } from "~/lib/user"; interface ConnectedUserProps { - avatarSrc: string, - joinedFor: string, - likeCount: string, - username: string, + alt: string; + src?: string; + userId?: string; } -export default function ConnectedUser({ avatarSrc, joinedFor, likeCount, username }: Readonly) { +const ConnectedUser: React.FC = memo(({ alt, src, userId }) => { + // TODO[MH]: Implement user creation date and total likes + const userCreatedAt = Date.now() + const totalLikes = Math.floor(Math.random() * 1000); + return ( -
- -
-

- {username} -

-

- Joined for {joinedFor} / {likeCount} likes -

+
+ +
+
{alt}
+
+ Joined {calculateElapsedTime(userCreatedAt)} / +{totalLikes} likes +
- ) -} \ No newline at end of file + ); +}); + +ConnectedUser.displayName = "ConnectedUser"; + +export default ConnectedUser; diff --git a/src/app/_components/nav-bar.tsx b/src/app/_components/nav-bar.tsx deleted file mode 100644 index ce522af..0000000 --- a/src/app/_components/nav-bar.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'use client'; - -import React from 'react'; -import ConnectedUser from "@components/connected-user"; -import NavLink from "@components/nav-link"; -import {Button, buttonVariants} from "@components/ui/button"; - -export default function NavBar() { - const isLoggedIn = false; - - return ( - - ); -} diff --git a/src/app/_components/nav-link.tsx b/src/app/_components/nav-link.tsx deleted file mode 100644 index 8fa89ad..0000000 --- a/src/app/_components/nav-link.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -import Link from "next/link"; -import {type ButtonProps, buttonVariants} from "@components/ui/button"; -import React from "react"; - - -interface NavLinkProps extends ButtonProps { - href: string; - name: string; -} - -export default function NavLink({ href, name, variant} : Readonly) { - return ( - {name} - ); -} \ No newline at end of file diff --git a/src/app/_components/nav-links.tsx b/src/app/_components/nav-links.tsx new file mode 100644 index 0000000..ad02527 --- /dev/null +++ b/src/app/_components/nav-links.tsx @@ -0,0 +1,32 @@ +import { buttonVariants } from "@components/ui/button"; +import Link from "next/link"; +import React, { memo } from "react"; +import { type VariantProps } from "cva"; + +interface NavLinkItem extends VariantProps { + href: string; + label: string; +} + +export interface NavLinksProps extends React.HTMLAttributes { + links: NavLinkItem[]; +} + +const NavLinks: React.FC = memo(({ links, className, ...props }) => { + return ( +
+ {links.map(({ href, label, variant }) => ( + + {label} + + ))} +
+ ); +}); +NavLinks.displayName = "NavLinks"; + +export { NavLinks }; diff --git a/src/app/_components/stories/ui/avatar.stories.tsx b/src/app/_components/stories/ui/avatar.stories.tsx index 7a03a82..12a1d46 100644 --- a/src/app/_components/stories/ui/avatar.stories.tsx +++ b/src/app/_components/stories/ui/avatar.stories.tsx @@ -1,6 +1,7 @@ import type {Meta, StoryObj} from "@storybook/react"; -import Avatar from "@components/ui/avatar"; +import {Avatar, AvatarFallback, AvatarImage} from "@components/ui/avatar"; +import React from "react"; const meta: Meta = { title: "UI/Avatar", @@ -10,16 +11,26 @@ export default meta; type Story = StoryObj; -export const Default: Story = { - args: { - src: "https://github.com/shadcn.png", - alt: "some alt text", - }, +const Template = { + render: () => { + return ( + + + The y in Morty + + ); + } } -export const Fallback: Story = { + +export const Default: Story = { + ...Template, +} + + +export const Small: Story = { args: { - src: "", - alt: "some alt text", + size: "small", }, -} + ...Template, +} \ No newline at end of file diff --git a/src/app/_components/ui/avatar.tsx b/src/app/_components/ui/avatar.tsx index 38c849b..9a76c52 100644 --- a/src/app/_components/ui/avatar.tsx +++ b/src/app/_components/ui/avatar.tsx @@ -2,26 +2,55 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar"; import React, {forwardRef} from "react"; import {cn} from "~/lib/utils"; -import {createFallbackAvatar} from "~/lib/avatar"; +import {cva, type VariantProps} from "cva"; -export const AvatarContainer = forwardRef< +export const avatarVariants = cva( + "relative flex shrink-0 overflow-hidden rounded-full transition-all", + { + variants: { + size: { + default: "h-[45px] w-[45px]", + small: "h-[25px] w-[25px]", + }, + }, + defaultVariants: { + size: "default", + }, + } +); + +export interface AvatarProps extends + React.ComponentPropsWithoutRef, + VariantProps { + transitionDuration?: string; +} + +const Avatar = forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarContainer.displayName = AvatarPrimitive.Root.displayName; + AvatarProps +>( + ( + { className, size, transitionDuration = "0.3s", ...props }, + ref + ) => ( + + ) +); + +Avatar.displayName = AvatarPrimitive.Root.displayName; -export const AvatarImage = forwardRef< +const AvatarImage = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( @@ -33,7 +62,7 @@ export const AvatarImage = forwardRef< )); AvatarImage.displayName = AvatarPrimitive.Image.displayName; -export const AvatarFallback = forwardRef< +const AvatarFallback = forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( @@ -48,39 +77,4 @@ export const AvatarFallback = forwardRef< )); AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; - -// TODO[MH]: Improve how we retrieve the first name that forms the seed for the avatar and the alt value. -export default function Avatar({ alt, src, ...props }: React.HTMLAttributes & { - src: string; - alt: string; -}) { - let imageURI = src; - const isValidSrc = () => { - if (!src) return false; - const image = new Image(); - image.src = src; - if (image.complete) { - return true; - } else { - image.onload = () => { - return true; - } - image.onerror = () => { - return false; - } - } - } - - if (!isValidSrc()) { - imageURI = createFallbackAvatar(alt); - } - - console.log(`URI: ${imageURI}`); - return ( - - - {alt} - - ); -} - +export { Avatar, AvatarImage, AvatarFallback }; \ No newline at end of file diff --git a/src/app/_components/ui/button.tsx b/src/app/_components/ui/button.tsx index a5b063e..906a4f2 100644 --- a/src/app/_components/ui/button.tsx +++ b/src/app/_components/ui/button.tsx @@ -6,13 +6,13 @@ import {cva, type VariantProps} from "class-variance-authority" import {cn} from "~/lib/utils" const buttonVariants = cva([ - "h-9", - "w-60", + "h-[45px]", + "w-[257px]", "inline-flex", "items-center", "justify-center", "whitespace-nowrap", - "rounded-lg", + "rounded-[10px]", "text-base", "text-light", "font-normal", diff --git a/src/app/_components/ui/card.tsx b/src/app/_components/ui/card.tsx index ca1e6da..d92dfd0 100644 --- a/src/app/_components/ui/card.tsx +++ b/src/app/_components/ui/card.tsx @@ -1,19 +1,13 @@ import * as React from "react"; -import {cn} from "~/lib/utils"; +import { cn } from "~/lib/utils"; -const Card = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( +type CardProps = React.HTMLAttributes + +const Card = React.forwardRef(({ className, ...props }, ref) => (
->(({ className, ...props }, ref) => ( +type CardHeaderProps = React.HTMLAttributes + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
->(({ className, ...props }, ref) => ( +type CardTitleProps = React.HTMLAttributes + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => (

->(({ className, ...props }, ref) => ( +type CardDescriptionProps = React.HTMLAttributes + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => (

->(({ className, ...props }, ref) => ( +type CardContentProps = React.HTMLAttributes + +const CardContent = React.forwardRef(({ className, ...props }, ref) => (

->(({ className, ...props }, ref) => ( +type CardFooterProps = React.HTMLAttributes + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
= memo(({ title, subtitle }) => { + return ( +
+
+

{title}

+ {subtitle} +
+ +
+ ); +}); +Header.displayName = "Header"; + +export default Header; diff --git a/src/app/_components/ui/input.tsx b/src/app/_components/ui/input.tsx index 3d18fcb..15949c6 100644 --- a/src/app/_components/ui/input.tsx +++ b/src/app/_components/ui/input.tsx @@ -1,12 +1,11 @@ -import * as React from "react" +import React, {memo} from "react"; +import { cn } from "~/lib/utils"; +import { Label } from "@components/ui/label"; -import {cn} from "~/lib/utils" -import {Label} from "@components/ui/label"; - -export type InputProps = React.InputHTMLAttributes +export type InputProps = React.InputHTMLAttributes; const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { + ({ className, type = "text", ...props }, ref) => { return ( ( ref={ref} {...props} /> - ) + ); } -) -Input.displayName = "Input" - +); +Input.displayName = "Input"; -function InputWithLabel() { +const InputWithLabel: React.FC = memo(() => { return (
- ) -} + ); +}); +InputWithLabel.displayName = "InputWithLabel"; -export { Input , InputWithLabel} +export { Input, InputWithLabel }; diff --git a/src/app/_components/ui/nav-bar.tsx b/src/app/_components/ui/nav-bar.tsx new file mode 100644 index 0000000..a439765 --- /dev/null +++ b/src/app/_components/ui/nav-bar.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React, { memo } from 'react'; +import { NavLinks } from "@components/nav-links"; +import { signOut, useSession } from "next-auth/react"; +import { Button } from "@components/ui/button"; +import ConnectedUser from "@components/connected-user"; + +const NavBar: React.FC = memo(() => { + const { data: session } = useSession(); + const image = session?.user?.image ?? undefined; + const justify = session ? "" : "justify-between"; + + return ( + + ); +}); + +NavBar.displayName = "NavBar"; + +export default NavBar; diff --git a/src/app/_components/ui/section.tsx b/src/app/_components/ui/section.tsx new file mode 100644 index 0000000..65a2508 --- /dev/null +++ b/src/app/_components/ui/section.tsx @@ -0,0 +1,24 @@ +import React, {forwardRef} from "react"; + +import type {HTMLAttributes} from "react"; + +interface SectionProps extends HTMLAttributes { + children: React.ReactNode +} + +const Section = forwardRef( + ({ className, children, ...props }, ref) => { + return ( +
+ {children} +
+ ) + } +) +Section.displayName = "Section" + +export default Section \ No newline at end of file diff --git a/src/app/_components/user-avatar.tsx b/src/app/_components/user-avatar.tsx new file mode 100644 index 0000000..374a4c7 --- /dev/null +++ b/src/app/_components/user-avatar.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React, { useEffect, useState, memo } from "react"; +import { createFallbackAvatar } from "~/lib/avatar"; +import {Avatar, AvatarFallback, AvatarImage, type avatarVariants} from "@components/ui/avatar"; +import {type VariantProps} from "cva"; + +interface UserAvatarProps extends VariantProps{ + alt: string; + src?: string; +} + +const UserAvatar: React.FC = ({ alt, src }) => { + const [imageURI, setImageURI] = useState(src); + + useEffect(() => { + const validateSrc = async (src: string) => { + try { + const response = await fetch(src); + if (response.ok) { + setImageURI(src); + } else { + setImageURI(createFallbackAvatar(alt)); + } + } catch { + setImageURI(createFallbackAvatar(alt)); + } + }; + + if (src) { + void validateSrc(src); // Explicitly marking the promise as ignored + } else { + setImageURI(createFallbackAvatar(alt)); + } + }, [src, alt]); + + return ( + + + {alt} + + ); +}; + +export default memo(UserAvatar); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0495441..d854254 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,32 +1,43 @@ -import "~/styles/globals.css"; +'use client'; -import {TRPCReactProvider} from "~/trpc/react"; +import "~/styles/globals.css"; +import { TRPCReactProvider } from "~/trpc/react"; import React from "react"; -import {Inter} from 'next/font/google' +import { Inter } from 'next/font/google'; +import { SessionProvider } from "next-auth/react"; +import Head from "next/head"; +import { metadata } from "~/lib/utils"; const inter = Inter({ - weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], - subsets: ['latin'], + weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], + subsets: ['latin'], +}); -}) +interface RootLayoutProps { + children: React.ReactNode; +} -export const metadata = { - authors: [{ name: 'Mango Habanero', url: 'https://mango-habanero.dev/' }], - title: "Gallery", - description: "A gallery application.", - icons: [{ rel: "icon", url: "/favicon.ico" }], +const RootLayout: React.FC = ({ children }) => { + return ( + + + {metadata.title} + + + {metadata.icons.map((icon) => ( + + ))} + {metadata.authors.map((author) => ( + + ))} + + + + {children} + + + + ); }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} +export default RootLayout; diff --git a/src/app/page.tsx b/src/app/page.tsx index abc5e77..9c68348 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,19 @@ -import NavBar from "@components/nav-bar"; +import React, { memo } from 'react'; +import Header from "@components/ui/header"; +import Section from "@components/ui/section"; +import NavBar from "@components/ui/nav-bar"; +const Home: React.FC = () => { + return ( +
+ +
+
+
placeholder
+
placeholder
+
+
+ ); +}; -export default async function Home() { - return ( -
- -
-
-
- ); -} +export default memo(Home); diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx new file mode 100644 index 0000000..68596b4 --- /dev/null +++ b/src/app/sign-in/page.tsx @@ -0,0 +1,35 @@ +'use client'; + +import React, { useEffect, memo } from "react"; +import { Button } from "@components/ui/button"; +import { signIn, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +const SignIn: React.FC = () => { + const router = useRouter(); + const { status } = useSession(); + + useEffect(() => { + if (status === "authenticated") { + router.push("/"); + } + }, [status, router]); + + return ( +
+
+

Sign In

+ +
+
+ ); +}; + +export default memo(SignIn); diff --git a/src/lib/user.ts b/src/lib/user.ts new file mode 100644 index 0000000..4bad5c8 --- /dev/null +++ b/src/lib/user.ts @@ -0,0 +1,56 @@ +/** + * Represents a unit of time for elapsed time calculations. + */ +interface TimeUnit { + threshold: number; // The maximum value (in seconds) for this unit to be used + unit: string; // The abbreviation for the unit (e.g., "s", "m", "h") +} + +/** + * Calculates a human-readable representation of the time elapsed since a given date. + * + * N/B: To keep the dependency graph lean, calculateElapsedTime is implemented + * without external libraries. Should the need for a more accurate representation of + * the time difference arise, consider: https://www.npmjs.com/package/date-fns + * + * This function determines the most appropriate unit of time + * to express the difference between the current time and the provided `joinedAt` date. + * If the difference is zero, "now" is returned. + * + * @param joinedAt - The date from which to calculate the elapsed time. + * @returns A string indicating the elapsed time in a human-readable format (e.g., "5m", "3h", "1y", or "now"). + */ +export function calculateElapsedTime(joinedAt: number): string { + const now = new Date(); + const diffInSeconds = Math.round((now.getTime() - new Date().setTime(joinedAt)) / 1000); + + if (diffInSeconds === 0) { + return "now"; + } + + const timeUnits: TimeUnit[] = [ + { threshold: 60, unit: 's' }, // 60 seconds in a minute + { threshold: 3600, unit: 'm' }, // 60 minutes in an hour + { threshold: 86400, unit: 'h' }, // 24 hours in a day + { threshold: 2592000, unit: 'd' }, // 30 days in a month + { threshold: 31536000, unit: 'y' } // 365 days in a year + ]; + + for (let i = 0; i < timeUnits.length; i++) { + const { threshold, unit } = timeUnits[i]!; + if (diffInSeconds < threshold) { + // For the first unit (seconds), return the diff directly + if (i === 0) { + return `for ${diffInSeconds}${unit}`; + } + // For subsequent units, calculate the amount in the previous unit + const prevUnit = timeUnits[i - 1]!; + return `${Math.round(diffInSeconds / prevUnit.threshold)}${prevUnit.unit}`; + } + } + + // If it exceeds the largest threshold, calculate in years + const lastUnit = timeUnits[timeUnits.length - 1]!; + return `${Math.round(diffInSeconds / lastUnit.threshold)}${lastUnit.unit}`; +} + diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4b4b1f6..1642535 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,14 @@ import {type ClassValue, clsx} from "clsx" import {twMerge} from "tailwind-merge" +import {type Metadata} from "next"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const metadata = { + authors: [{ name: 'Mango Habanero', url: 'https://mango-habanero.dev/' }], + title: "Gallery", + description: "A gallery application.", + icons: [{ rel: "icon", url: "/favicon.ico" }], +} satisfies Metadata; \ No newline at end of file diff --git a/src/server/auth.ts b/src/server/auth.ts index 117984c..fc24dc8 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -52,16 +52,10 @@ export const authOptions: NextAuthOptions = { clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET, }), - /** - * ...add more providers here. - * - * Most other providers require a bit more work than the Discord provider. For example, the - * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account - * model. Refer to the NextAuth.js docs for the provider you want to use. Example: - * - * @see https://next-auth.js.org/providers/github - */ ], + pages: { + signIn: "/sign-in" + } }; /**