Merge branch 'projectpage'
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ yarn-error.log*
|
|||||||
# clerk configuration (can include secrets)
|
# clerk configuration (can include secrets)
|
||||||
/.clerk/
|
/.clerk/
|
||||||
.worktrees
|
.worktrees
|
||||||
|
.claudesession
|
||||||
|
|||||||
@@ -1,30 +1,41 @@
|
|||||||
import { useGSAP } from "@gsap/react";
|
import { useGSAP } from "@gsap/react";
|
||||||
import { useRef, type ReactNode } from "react";
|
import { useRef, type HTMLAttributes, type ReactNode } from "react";
|
||||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
import { SplitText } from "gsap/SplitText";
|
import { SplitText } from "gsap/SplitText";
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
const AnimateTextIn = ({children,animation="type",position}:{children:ReactNode,animation?:"type"|"slide",position:gsap.Position}) => {
|
import { cn } from "~/lib/utils";
|
||||||
|
const AnimateTextIn = ({
|
||||||
|
children,
|
||||||
|
animation = "type",
|
||||||
|
position,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: ReactNode,
|
||||||
|
animation?: "type" | "slide",
|
||||||
|
position: gsap.Position,
|
||||||
|
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||||
|
}) => {
|
||||||
const el = useRef<HTMLDivElement>(null)
|
const el = useRef<HTMLDivElement>(null)
|
||||||
const gsapContext = useGsapContext();
|
const gsapContext = useGsapContext();
|
||||||
useGSAP(() => {
|
useGSAP(() => {
|
||||||
const rect = el.current?.getBoundingClientRect()
|
const rect = el.current?.getBoundingClientRect()
|
||||||
const isInView = rect && rect.top < window.innerHeight
|
const isInView = rect && rect.top < window.innerHeight
|
||||||
const chars = new SplitText(el.current,{type:'chars'})
|
const chars = new SplitText(el.current, { type: 'chars' })
|
||||||
gsapContext?.addAnimation(gsap.to(el.current,{opacity:100, duration:0}),0)
|
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100, duration: 0 }), 0)
|
||||||
const fromVars = animation === "slide"
|
const fromVars = animation === "slide"
|
||||||
? {opacity:0, x:-10, duration: 0.2, stagger: {each: 0.08}, ease:'bounce.inOut'}
|
? { opacity: 0, x: -10, duration: 0.2, stagger: { each: 0.08 }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
||||||
: {opacity:0, duration: 0.01, stagger: {each: 0.04}, ease: 'bounce.inOut'}
|
: { opacity: 0, duration: 0.01, stagger: { each: 0.04 }, ease: 'bounce.inOut', onComplete: () => chars.revert() }
|
||||||
if (isInView) {
|
if (isInView) {
|
||||||
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars),position)
|
gsapContext?.addAnimation(gsap.from(chars.chars, fromVars), position)
|
||||||
} else {
|
} else {
|
||||||
gsap.from(chars.chars, { ...fromVars, scrollTrigger: { trigger: el.current, start: 'top 85%', scroller: gsapContext?.getScroller() } })
|
gsap.from(chars.chars, { ...fromVars, scrollTrigger: { trigger: el.current, start: 'top 85%', scroller: gsapContext?.getScroller() } })
|
||||||
}
|
}
|
||||||
}, { dependencies: [] })
|
}, { dependencies: [] })
|
||||||
return (
|
return (
|
||||||
<div ref={el} className="opacity-0">
|
<div ref={el} className={cn(className,"opacity-0")}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AnimateTextIn;
|
export default AnimateTextIn;
|
||||||
|
|||||||
22
src/app/_components/Animated/AnimatePopUp.tsx
Normal file
22
src/app/_components/Animated/AnimatePopUp.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { type HTMLAttributes, type ReactNode } from "react";
|
||||||
|
import AnimatedDiv from "./AnimatedDiv";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
const AnimatePopUp = ({
|
||||||
|
children,
|
||||||
|
position,
|
||||||
|
className,
|
||||||
|
duration=1,
|
||||||
|
ease='elastic'
|
||||||
|
}:{
|
||||||
|
children:ReactNode
|
||||||
|
position:gsap.Position,
|
||||||
|
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||||
|
duration?:number,
|
||||||
|
ease?:gsap.EaseString|gsap.EaseFunction
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AnimatedDiv children={children} position={position} className={cn(className,'h-0 translate-y-[50] overflow-hidden')} height='auto' y={0} overflow='' ease={ease} duration={duration} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatePopUp;
|
||||||
41
src/app/_components/Animated/AnimatedDiv.tsx
Normal file
41
src/app/_components/Animated/AnimatedDiv.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import gsap from "gsap";
|
||||||
|
import { type HTMLAttributes,
|
||||||
|
type ReactNode, useLayoutEffect, useRef } from "react";
|
||||||
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
|
const AnimatedDiv = (
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
position,
|
||||||
|
className,
|
||||||
|
animationMode='to',
|
||||||
|
...tweenVars
|
||||||
|
}:
|
||||||
|
gsap.TweenVars & {
|
||||||
|
children:ReactNode,
|
||||||
|
position:gsap.Position,
|
||||||
|
animationMode?: 'from'|'to',
|
||||||
|
className?:HTMLAttributes<HTMLDivElement>['className']
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const div = useRef<HTMLDivElement>(null);
|
||||||
|
const gsapContext = useGsapContext()
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let tween:gsap.core.Tween;
|
||||||
|
switch(animationMode) {
|
||||||
|
case 'from':
|
||||||
|
tween = gsap.from(div.current,tweenVars);
|
||||||
|
break;
|
||||||
|
case 'to':
|
||||||
|
tween = gsap.to(div.current,tweenVars);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gsapContext?.addAnimation(tween,position)
|
||||||
|
},[])
|
||||||
|
return (
|
||||||
|
<div ref={div} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnimatedDiv;
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef } from "react";
|
import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef,type ReactNode } from "react";
|
||||||
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
||||||
import { SplitText } from "gsap/SplitText";
|
import { SplitText } from "gsap/SplitText";
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
const AnimatedPageTitle = (
|
const AnimatedPageTitle = (
|
||||||
{ text, position }: { text: string, position:gsap.Position }
|
{ children, position }: { children: ReactNode, position:gsap.Position }
|
||||||
) => {
|
) => {
|
||||||
const el = useRef<HTMLHeadingElement>(null)
|
const el = useRef<HTMLHeadingElement>(null)
|
||||||
const gsapContext = useGsapContext();
|
const gsapContext = useGsapContext();
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
console.log("add animated title with:",position)
|
const split = new SplitText(el.current, { type: "lines,chars", autoSplit:true })
|
||||||
const split = new SplitText(el.current, { type: "chars" })
|
|
||||||
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100 }),position)
|
gsapContext?.addAnimation(gsap.to(el.current, { opacity: 100 }),position)
|
||||||
gsapContext?.addAnimation(gsap.from(split.chars, {
|
gsapContext?.addAnimation(gsap.from(split.chars, { id: 'titlesplit',
|
||||||
stagger: 0.05, rotate: -90, opacity: 0, x: -10
|
stagger: 0.05, rotate: -90, opacity: 0, x: -10, onComplete: () => {split.revert()}
|
||||||
}),'>')
|
}),'>')
|
||||||
},[])
|
},[])
|
||||||
return (
|
return (
|
||||||
<h1 className="text-4xl opacity-0 font-bold text-balance w-full" ref={el}> {text} </h1>
|
<h1 className="text-4xl break-keep opacity-0 font-bold text-balance w-full" ref={el}> {children} </h1>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ThemeSwitch } from "./ThemeSwitch"
|
|||||||
|
|
||||||
export default function TopNav() {
|
export default function TopNav() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed lg:w-full right-0 z-50 lg:bg-background">
|
<div className="fixed backdrop-blur-md lg:w-full right-0 z-50">
|
||||||
<nav className="flex flex-col-reverse lg:flex-row flex-wrap w-20 lg:w-full outline-1 lg:h-10 h-full">
|
<nav className="flex flex-col-reverse lg:flex-row flex-wrap w-20 lg:w-full outline-1 lg:h-10 h-full">
|
||||||
<div className="flex flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row">
|
<div className="flex flex-wrap lg:h-full w-20 lg:w-fit lg:flex-row">
|
||||||
<Button className="flex h-10 lg:h-full w-full lg:w-20" asChild variant="outline">
|
<Button className="flex h-10 lg:h-full w-full lg:w-20" asChild variant="outline">
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import { useGSAP } from '@gsap/react'
|
import { useGSAP } from '@gsap/react'
|
||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
import { SplitText } from 'gsap/SplitText'
|
import { SplitText } from 'gsap/SplitText'
|
||||||
import { ScrollTrigger } from 'gsap/all'
|
import { ScrollTrigger, GSDevTools } from 'gsap/all'
|
||||||
import { createContext, useCallback, useContext, useEffect, useRef, type ReactNode } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useRef, type ReactNode } from 'react'
|
||||||
|
|
||||||
gsap.registerPlugin(useGSAP)
|
gsap.registerPlugin(useGSAP)
|
||||||
gsap.registerPlugin(ScrollTrigger)
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
gsap.registerPlugin(SplitText)
|
gsap.registerPlugin(SplitText)
|
||||||
|
gsap.registerPlugin(GSDevTools)
|
||||||
const GsapContext = createContext<{
|
const GsapContext = createContext<{
|
||||||
addAnimation: (
|
addAnimation: (
|
||||||
animation: gsap.core.TimelineChild,
|
animation: gsap.core.TimelineChild,
|
||||||
@@ -56,7 +57,10 @@ export default function GsapProvider({ children }: { children: ReactNode }) {
|
|||||||
}, [])
|
}, [])
|
||||||
useGSAP(() => {
|
useGSAP(() => {
|
||||||
if (!tl.current) {
|
if (!tl.current) {
|
||||||
tl.current = gsap.timeline({ paused: true })
|
tl.current = gsap.timeline({ paused: true, id:'mainTimeline', onComplete: ()=>{
|
||||||
|
console.log('timeline revert')
|
||||||
|
gsap.getById('mainTimeline')?.revert()
|
||||||
|
} })
|
||||||
}
|
}
|
||||||
return () => { console.log("gsap cleanup") }
|
return () => { console.log("gsap cleanup") }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default function CreateUpdateStackForm(params: { className?: string, enti
|
|||||||
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
|
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
|
||||||
function onSubmit(values: z.infer<typeof schemas.insert>) {
|
function onSubmit(values: z.infer<typeof schemas.insert>) {
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
params.entity ?
|
id ?
|
||||||
updateMutation.mutate(values) :
|
updateMutation.mutate(values) :
|
||||||
createMutation.mutate(values);
|
createMutation.mutate(values);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,54 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { trpc } from "~/app/_trpc/Client";
|
import { trpc } from "~/app/_trpc/Client";
|
||||||
import * as Card from "~/components/ui/card";
|
import * as Card from "~/components/ui/card";
|
||||||
import { useTimeLine } from "../_providers/GsapProvicer";
|
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||||
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
||||||
import { Spinner } from "~/components/ui/spinner";
|
import { Spinner } from "~/components/ui/spinner";
|
||||||
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||||
export default function MusicPage() {
|
export default function MusicPage() {
|
||||||
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
const { data: tracks, isLoading } = trpc.music.list.useQuery();
|
||||||
useTimeLine(tracks)
|
useTimeLine(tracks)
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="w-full h-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
|
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||||
<AnimatedPageTitle position={0} text="Just Some Music I Made" />
|
<AnimatedPageTitle position={0}><span>Just Some </span> <span>Music I Made</span> </AnimatedPageTitle>
|
||||||
<AnimateTextIn position={0.5}>
|
<div className="flex flex-wrap h-fit content-center">
|
||||||
<div className="flex flex-col lg:flex-row h-fit content-center">
|
<AnimateTextIn className="flex flex-wrap mr-[1em]" position={0.5}>
|
||||||
<p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p>
|
<div><p className="break-after-avoid mr-[1em]">All works on this page are licensed under:</p></div>
|
||||||
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
|
<div><a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a></div>
|
||||||
|
</AnimateTextIn>
|
||||||
|
<AnimatePopUp position={2} className="items-center content-center">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
|
<img className="max-w-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" />
|
||||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
|
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" />
|
||||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
|
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" alt="" />
|
||||||
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
|
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimatePopUp>
|
||||||
</AnimateTextIn>
|
</div>
|
||||||
|
<div className="pt-10" />
|
||||||
{tracks && tracks.map((track, i) => (
|
{tracks && tracks.map((track, i) => (
|
||||||
<Card.AnimatedCard key={track.id} position={i + 1}>
|
<div key={track.id}>
|
||||||
<Card.CardHeader>
|
<Card.AnimatedCard position={i + 1}>
|
||||||
<AnimateTextIn position={i + 1.2} animation="slide">
|
<Card.CardHeader>
|
||||||
<Card.CardTitle>{track.title}</Card.CardTitle>
|
<AnimateTextIn position={i + 1.2} animation="slide">
|
||||||
</AnimateTextIn>
|
<Card.CardTitle>{track.title}</Card.CardTitle>
|
||||||
</Card.CardHeader>
|
</AnimateTextIn>
|
||||||
<Card.CardContent className="flex flex-col gap-3">
|
</Card.CardHeader>
|
||||||
{track.description && (
|
<Card.CardContent className="flex flex-col gap-3">
|
||||||
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
{track.description && (
|
||||||
)}
|
<p className="text-sm text-muted-foreground gsapant">{track.description}</p>
|
||||||
<audio controls className="w-full player" src={track.fileUrl}>
|
)}
|
||||||
Your browser does not support the audio element.
|
<AnimatePopUp position={i + 1.3}>
|
||||||
</audio>
|
<audio controls className="w-full player" src={track.fileUrl}>
|
||||||
</Card.CardContent>
|
Your browser does not support the audio element.
|
||||||
</Card.AnimatedCard>
|
</audio>
|
||||||
|
</AnimatePopUp>
|
||||||
|
</Card.CardContent>
|
||||||
|
</Card.AnimatedCard>
|
||||||
|
<div className="pt-5" />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{!isLoading && !tracks?.length &&
|
{!isLoading && !tracks?.length &&
|
||||||
<div className="flex justify-center items-center text-muted-foreground">
|
<div className="flex justify-center items-center text-muted-foreground">
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ import * as Card from "~/components/ui/card";
|
|||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { StackBadge } from "~/components/StackBadge";
|
import { StackBadge } from "~/components/StackBadge";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
import AnimatedPageTitle from "../_components/Animated/AnimatedPageTitle";
|
||||||
|
import AnimateTextIn from "../_components/Animated/AnimateIn";
|
||||||
|
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||||
|
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
||||||
|
useTimeLine(projects)
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
|
||||||
@@ -26,68 +32,83 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
|
<ScrollArea className="px-10 lg:px-0 w-full h-full max-w-4xl mx-auto pt-10">
|
||||||
{projects.map((project) => (
|
<AnimatedPageTitle position={0}><span>Project I've Been</span><span> Working on</span> </AnimatedPageTitle>
|
||||||
<Card.Card key={project.id}>
|
<div className="pt-10" />
|
||||||
<Card.CardHeader>
|
{projects.map((project, i) => (
|
||||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
<div key={i}>
|
||||||
<Card.CardTitle>{project.title}</Card.CardTitle>
|
<Card.AnimatedCard position={i + 1.2} key={project.id}>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<Card.CardHeader>
|
||||||
{project.sourceType && (
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
<AnimateTextIn position={i + 1.4} animation="slide"><Card.CardTitle>{project.title}</Card.CardTitle></AnimateTextIn>
|
||||||
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
<div className="flex gap-2 flex-wrap">
|
||||||
</Badge>
|
{project.sourceType && (
|
||||||
)}
|
<AnimatePopUp position={i + 2} duration={2}>
|
||||||
{project.releaseStatus && (
|
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
|
||||||
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
|
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
|
||||||
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
|
</Badge>
|
||||||
</Badge>
|
</AnimatePopUp>
|
||||||
)}
|
)}
|
||||||
|
{project.releaseStatus && (
|
||||||
|
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
|
||||||
|
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card.CardHeader>
|
||||||
</Card.CardHeader>
|
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
|
||||||
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
|
<Card.CardContent className="flex flex-col gap-3">
|
||||||
<Card.CardContent className="flex flex-col gap-3">
|
{project.description && (
|
||||||
{project.description && (
|
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
<AnimatePopUp position={i + 1.4} duration={project.description.length / 20}>
|
||||||
<Markdown>{project.description}</Markdown>
|
<AnimateTextIn position={i + 1.5} animation="slide"><Markdown>{project.description}</Markdown></AnimateTextIn></AnimatePopUp>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(project.sourceLink || project.releaseLink) && (
|
<div className="flex flex-row">
|
||||||
<div className="flex gap-3 flex-wrap">
|
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
||||||
{project.sourceLink && (
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<a
|
{project.techStack.stackItems.map((item, k) => (
|
||||||
href={project.sourceLink}
|
<AnimatePopUp key={k} position={(i + 2) + k * 0.5}> <StackBadge key={item} item={item} /> </AnimatePopUp>
|
||||||
target="_blank"
|
))}
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
|
||||||
>
|
|
||||||
Source
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
{project.releaseLink && (
|
{(project.sourceLink || project.releaseLink) && (
|
||||||
<a
|
<div className="ml-auto flex-col lg:flex-row justify-center gap-5">
|
||||||
href={project.releaseLink}
|
{project.sourceLink &&
|
||||||
target="_blank"
|
<Button variant='outline' className="cursor-pointer mb-3 lg:mb-0 lg:mr-3 min-w-18">
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
|
href={project.sourceLink}
|
||||||
>
|
target="_blank"
|
||||||
Live
|
rel="noopener noreferrer"
|
||||||
</a>
|
className='items-center'
|
||||||
|
>
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{project.releaseLink &&
|
||||||
|
<Button variant='default' className="cursor-pointer min-w-18 items-center">
|
||||||
|
<a
|
||||||
|
href={project.releaseLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className='items-center'
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Card.CardContent>
|
||||||
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
|
)}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
</Card.AnimatedCard>
|
||||||
{project.techStack.stackItems.map((item) => (
|
<div className="pt-5" />
|
||||||
<StackBadge key={item} item={item} />
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card.CardContent>
|
|
||||||
)}
|
|
||||||
</Card.Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user