feat: rebuild portfolio with React + TanStack Start
This commit is contained in:
40
src/components/Footer.tsx
Normal file
40
src/components/Footer.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { GitHubIcon, LinkedInIcon } from '#/components/icons'
|
||||
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]">
|
||||
<div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left">
|
||||
<p className="m-0 text-sm">
|
||||
© {year} Mohammad. All rights reserved.
|
||||
</p>
|
||||
<p className="island-kicker m-0">
|
||||
Built by Mohammad · Copenhagen, Denmark
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/moh682"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
<GitHubIcon width={18} height={18} />
|
||||
GitHub
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.linkedin.com/in/moh682"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
<LinkedInIcon width={18} height={18} />
|
||||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
55
src/components/Header.tsx
Normal file
55
src/components/Header.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GitHubIcon } from '#/components/icons'
|
||||
import { CONTACT_EMAIL } from '#/data/portfolio'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg">
|
||||
<nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4">
|
||||
<h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" />
|
||||
Mohammad
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||
<a
|
||||
href="https://github.com/moh682"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
||||
>
|
||||
<span className="sr-only">GitHub profile</span>
|
||||
<GitHubIcon width={24} height={24} />
|
||||
</a>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="order-3 flex w-full flex-wrap items-center gap-x-4 gap-y-1 pb-1 text-sm font-semibold sm:order-2 sm:w-auto sm:flex-nowrap sm:pb-0">
|
||||
<Link
|
||||
to="/"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<a href="/#experience" className="nav-link">
|
||||
Experience
|
||||
</a>
|
||||
<a href="/#skills" className="nav-link">
|
||||
Skills
|
||||
</a>
|
||||
<a href={`mailto:${CONTACT_EMAIL}`} className="nav-link">
|
||||
Contact
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
81
src/components/ThemeToggle.tsx
Normal file
81
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'auto'
|
||||
|
||||
function getInitialMode(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem('theme')
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
|
||||
return stored
|
||||
}
|
||||
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
|
||||
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
|
||||
if (mode === 'auto') {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', mode)
|
||||
}
|
||||
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>('auto')
|
||||
|
||||
useEffect(() => {
|
||||
const initialMode = getInitialMode()
|
||||
setMode(initialMode)
|
||||
applyThemeMode(initialMode)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto') {
|
||||
return
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyThemeMode('auto')
|
||||
|
||||
media.addEventListener('change', onChange)
|
||||
return () => {
|
||||
media.removeEventListener('change', onChange)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
function toggleMode() {
|
||||
const nextMode: ThemeMode =
|
||||
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
|
||||
setMode(nextMode)
|
||||
applyThemeMode(nextMode)
|
||||
window.localStorage.setItem('theme', nextMode)
|
||||
}
|
||||
|
||||
const label =
|
||||
mode === 'auto'
|
||||
? 'Theme mode: auto (system). Click to switch to light mode.'
|
||||
: `Theme mode: ${mode}. Click to switch mode.`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
|
||||
>
|
||||
{mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
55
src/components/blocks/block-renderer.tsx
Normal file
55
src/components/blocks/block-renderer.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { RichText } from './rich-text'
|
||||
import { Quote } from './quote'
|
||||
import { Media } from './media'
|
||||
import { Slider } from './slider'
|
||||
|
||||
import type { IRichText } from './rich-text'
|
||||
import type { IQuote } from './quote'
|
||||
import type { IMedia } from './media'
|
||||
import type { ISlider } from './slider'
|
||||
|
||||
// Union type of all block types
|
||||
export type Block = IRichText | IQuote | IMedia | ISlider
|
||||
|
||||
interface BlockRendererProps {
|
||||
blocks: Array<Block>
|
||||
}
|
||||
|
||||
/**
|
||||
* BlockRenderer - Renders dynamic content blocks from Strapi
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <BlockRenderer blocks={article.blocks} />
|
||||
* ```
|
||||
*/
|
||||
export function BlockRenderer({ blocks }: Readonly<BlockRendererProps>) {
|
||||
if (!blocks || blocks.length === 0) return null
|
||||
|
||||
const renderBlock = (block: Block) => {
|
||||
switch (block.__component) {
|
||||
case 'shared.rich-text':
|
||||
return <RichText {...block} />
|
||||
case 'shared.quote':
|
||||
return <Quote {...block} />
|
||||
case 'shared.media':
|
||||
return <Media {...block} />
|
||||
case 'shared.slider':
|
||||
return <Slider {...block} />
|
||||
default:
|
||||
// Log unknown block types in development
|
||||
console.warn('Unknown block type:', (block as any).__component)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{blocks.map((block, index) => (
|
||||
<div key={`${block.__component}-${block.id}-${index}`}>
|
||||
{renderBlock(block)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/components/blocks/index.ts
Normal file
14
src/components/blocks/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { BlockRenderer } from './block-renderer'
|
||||
export type { Block } from './block-renderer'
|
||||
|
||||
export { RichText } from './rich-text'
|
||||
export type { IRichText } from './rich-text'
|
||||
|
||||
export { Quote } from './quote'
|
||||
export type { IQuote } from './quote'
|
||||
|
||||
export { Media } from './media'
|
||||
export type { IMedia } from './media'
|
||||
|
||||
export { Slider } from './slider'
|
||||
export type { ISlider } from './slider'
|
||||
27
src/components/blocks/media.tsx
Normal file
27
src/components/blocks/media.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { StrapiImage } from '@/components/strapi-image'
|
||||
import type { TImage } from '@/types/strapi'
|
||||
|
||||
export interface IMedia {
|
||||
__component: 'shared.media'
|
||||
id: number
|
||||
file?: TImage
|
||||
}
|
||||
|
||||
export function Media({ file }: Readonly<IMedia>) {
|
||||
if (!file) return null
|
||||
|
||||
return (
|
||||
<figure className="my-8">
|
||||
<StrapiImage
|
||||
src={file.url}
|
||||
alt={file.alternativeText || ''}
|
||||
className="rounded-lg w-full"
|
||||
/>
|
||||
{file.alternativeText && (
|
||||
<figcaption className="mt-2 text-center text-sm text-gray-500">
|
||||
{file.alternativeText}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
19
src/components/blocks/quote.tsx
Normal file
19
src/components/blocks/quote.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface IQuote {
|
||||
__component: 'shared.quote'
|
||||
id: number
|
||||
body: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function Quote({ body, title }: Readonly<IQuote>) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-cyan-400 pl-6 py-4 my-6 bg-slate-800/30 rounded-r-lg">
|
||||
<p className="text-xl italic text-gray-300 leading-relaxed">{body}</p>
|
||||
{title && (
|
||||
<cite className="block mt-4 text-cyan-400 not-italic font-medium">
|
||||
— {title}
|
||||
</cite>
|
||||
)}
|
||||
</blockquote>
|
||||
)
|
||||
}
|
||||
11
src/components/blocks/rich-text.tsx
Normal file
11
src/components/blocks/rich-text.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
|
||||
export interface IRichText {
|
||||
__component: 'shared.rich-text'
|
||||
id: number
|
||||
body: string
|
||||
}
|
||||
|
||||
export function RichText({ body }: Readonly<IRichText>) {
|
||||
return <MarkdownContent content={body} />
|
||||
}
|
||||
28
src/components/blocks/slider.tsx
Normal file
28
src/components/blocks/slider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StrapiImage } from '@/components/strapi-image'
|
||||
import type { TImage } from '@/types/strapi'
|
||||
|
||||
export interface ISlider {
|
||||
__component: 'shared.slider'
|
||||
id: number
|
||||
files?: Array<TImage>
|
||||
}
|
||||
|
||||
export function Slider({ files }: Readonly<ISlider>) {
|
||||
if (!files || files.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{files.map((file, index) => (
|
||||
<figure key={file.id || index}>
|
||||
<StrapiImage
|
||||
src={file.url}
|
||||
alt={file.alternativeText || ''}
|
||||
className="rounded-lg w-full h-48 object-cover"
|
||||
/>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/components/icons/index.tsx
Normal file
43
src/components/icons/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
interface IconProps {
|
||||
readonly width?: number
|
||||
readonly height?: number
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
export function GitHubIcon({ width = 24, height = 24, className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LinkedInIcon({
|
||||
width = 24,
|
||||
height = 24,
|
||||
className,
|
||||
}: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.5 6h-2v6h2V6zm-1-1a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 5.5 5zm8.5 1h-2v1.28c0-.007-.003-.012-.003-.018C11.997 6.487 11.314 6 10.5 6 9.12 6 8 7.343 8 9v3h2V9c0-.55.447-1 1-1s1 .45 1 1v3h2V9c0-.685-.09-1.342-.5-2z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
92
src/components/markdown-content.tsx
Normal file
92
src/components/markdown-content.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string | undefined | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
const styles = {
|
||||
h1: 'text-3xl font-bold mb-6 text-white',
|
||||
h2: 'text-2xl font-bold mb-4 text-white',
|
||||
h3: 'text-xl font-bold mb-3 text-white',
|
||||
p: 'mb-4 leading-relaxed text-gray-300',
|
||||
a: 'text-cyan-400 hover:underline',
|
||||
ul: 'list-disc pl-6 mb-4 space-y-2 text-gray-300',
|
||||
ol: 'list-decimal pl-6 mb-4 space-y-2 text-gray-300',
|
||||
li: 'leading-relaxed',
|
||||
blockquote: 'border-l-4 border-cyan-400 pl-4 italic text-gray-400 my-4',
|
||||
code: 'bg-slate-800 px-2 py-1 rounded text-cyan-400 text-sm font-mono',
|
||||
pre: 'bg-slate-800 p-4 rounded-lg overflow-x-auto mb-4',
|
||||
table: 'w-full border-collapse mb-4',
|
||||
th: 'border border-slate-700 p-2 bg-slate-800 text-left text-white',
|
||||
td: 'border border-slate-700 p-2 text-gray-300',
|
||||
img: 'max-w-full h-auto rounded-lg my-4',
|
||||
hr: 'border-slate-700 my-8',
|
||||
strong: 'text-white font-semibold',
|
||||
}
|
||||
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
className = '',
|
||||
}: MarkdownContentProps) {
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className={`prose prose-invert max-w-none ${className}`}>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className={styles.h1}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 className={styles.h2}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 className={styles.h3}>{children}</h3>,
|
||||
p: ({ children }) => <p className={styles.p}>{children}</p>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className={styles.a}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className={styles.ul}>{children}</ul>,
|
||||
ol: ({ children }) => <ol className={styles.ol}>{children}</ol>,
|
||||
li: ({ children }) => <li className={styles.li}>{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className={styles.blockquote}>{children}</blockquote>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isCodeBlock = className?.includes('language-')
|
||||
if (isCodeBlock) {
|
||||
return (
|
||||
<pre className={styles.pre}>
|
||||
<code className="text-sm font-mono text-gray-300">
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return <code className={styles.code}>{children}</code>
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
table: ({ children }) => (
|
||||
<table className={styles.table}>{children}</table>
|
||||
),
|
||||
th: ({ children }) => <th className={styles.th}>{children}</th>,
|
||||
td: ({ children }) => <td className={styles.td}>{children}</td>,
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt || ''} className={styles.img} />
|
||||
),
|
||||
hr: () => <hr className={styles.hr} />,
|
||||
strong: ({ children }) => (
|
||||
<strong className={styles.strong}>{children}</strong>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/components/pagination.tsx
Normal file
120
src/components/pagination.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useRouter, useSearch } from '@tanstack/react-router'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PaginationProps {
|
||||
pageCount: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Pagination({ pageCount, className = '' }: PaginationProps) {
|
||||
const router = useRouter()
|
||||
const search = useSearch({ strict: false })
|
||||
const currentPage = Number((search as any)?.page) || 1
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
router.navigate({
|
||||
to: '.',
|
||||
search: (prev) => ({ ...prev, page }),
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const pages: Array<number | 'ellipsis'> = []
|
||||
const showEllipsis = pageCount > 7
|
||||
|
||||
if (showEllipsis) {
|
||||
pages.push(1)
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('ellipsis')
|
||||
}
|
||||
|
||||
const start = Math.max(2, currentPage - 1)
|
||||
const end = Math.min(pageCount - 1, currentPage + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (currentPage < pageCount - 2) {
|
||||
pages.push('ellipsis')
|
||||
}
|
||||
|
||||
if (pageCount > 1) {
|
||||
pages.push(pageCount)
|
||||
}
|
||||
} else {
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
const pageNumbers = getPageNumbers()
|
||||
|
||||
if (pageCount <= 1) return null
|
||||
|
||||
return (
|
||||
<nav className={`flex items-center justify-center gap-1 ${className}`}>
|
||||
{/* Previous Button */}
|
||||
<button
|
||||
onClick={() => currentPage > 1 && handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage <= 1
|
||||
? 'text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-300 hover:bg-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
|
||||
{/* Page Numbers */}
|
||||
<div className="flex items-center gap-1">
|
||||
{pageNumbers.map((page, index) =>
|
||||
page === 'ellipsis' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 py-2 text-gray-500 hidden md:block"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`min-w-[40px] px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-cyan-500 text-white'
|
||||
: 'text-gray-300 hover:bg-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<button
|
||||
onClick={() =>
|
||||
currentPage < pageCount && handlePageChange(currentPage + 1)
|
||||
}
|
||||
disabled={currentPage >= pageCount}
|
||||
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage >= pageCount
|
||||
? 'text-gray-600 cursor-not-allowed'
|
||||
: 'text-gray-300 hover:bg-slate-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="hidden sm:inline">Next</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
35
src/components/search.tsx
Normal file
35
src/components/search.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useRouter, useSearch } from '@tanstack/react-router'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
|
||||
interface SearchProps {
|
||||
readonly className?: string
|
||||
}
|
||||
|
||||
export function Search({ className = '' }: SearchProps) {
|
||||
const search = useSearch({ strict: false })
|
||||
const router = useRouter()
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
router.navigate({
|
||||
to: '.',
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
page: 1,
|
||||
query: term || undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}, 300)
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles..."
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
handleSearch(e.target.value)
|
||||
}
|
||||
defaultValue={(search as any)?.query || ''}
|
||||
className={`w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-colors ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
47
src/components/strapi-image.tsx
Normal file
47
src/components/strapi-image.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { getStrapiMedia } from '@/lib/strapi-utils'
|
||||
|
||||
interface StrapiImageProps {
|
||||
src: string | undefined | null
|
||||
alt?: string | null
|
||||
className?: string
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
}
|
||||
|
||||
export function StrapiImage({
|
||||
src,
|
||||
alt,
|
||||
className = '',
|
||||
width,
|
||||
height,
|
||||
}: StrapiImageProps) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (!src) return null
|
||||
|
||||
const imageUrl = getStrapiMedia(src)
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-slate-700 flex items-center justify-center text-slate-400 text-sm ${className}`}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<span>Image not available</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt || ''}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
className={`object-cover ${className}`}
|
||||
onError={() => setHasError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
114
src/data/loaders/articles.ts
Normal file
114
src/data/loaders/articles.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { sdk } from '@/data/strapi-sdk'
|
||||
import type {
|
||||
TArticle,
|
||||
TStrapiResponseCollection,
|
||||
TStrapiResponseSingle,
|
||||
} from '@/types/strapi'
|
||||
|
||||
const PAGE_SIZE = 3
|
||||
|
||||
const articles = sdk.collection('articles')
|
||||
|
||||
/**
|
||||
* Fetch articles with optional filtering, search, and pagination
|
||||
*/
|
||||
const getArticles = async (
|
||||
page?: number,
|
||||
category?: string,
|
||||
query?: string,
|
||||
) => {
|
||||
const filterConditions: Array<Record<string, unknown>> = []
|
||||
|
||||
// Add search query filter
|
||||
if (query) {
|
||||
filterConditions.push({
|
||||
$or: [
|
||||
{ title: { $containsi: query } },
|
||||
{ description: { $containsi: query } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// Add category filter
|
||||
if (category) {
|
||||
filterConditions.push({
|
||||
category: {
|
||||
slug: { $eq: category },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const filters =
|
||||
filterConditions.length === 0
|
||||
? undefined
|
||||
: filterConditions.length === 1
|
||||
? filterConditions[0]
|
||||
: { $and: filterConditions }
|
||||
|
||||
return articles.find({
|
||||
sort: ['createdAt:desc'],
|
||||
pagination: {
|
||||
page: page || 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
},
|
||||
populate: ['cover', 'author', 'category'],
|
||||
filters,
|
||||
}) as Promise<TStrapiResponseCollection<TArticle>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single article by documentId
|
||||
*/
|
||||
const getArticleById = async (documentId: string) => {
|
||||
return articles.findOne(documentId, {
|
||||
populate: ['cover', 'author', 'category', 'blocks.file', 'blocks.files'],
|
||||
}) as Promise<TStrapiResponseSingle<TArticle>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single article by slug
|
||||
*/
|
||||
const getArticleBySlug = async (slug: string) => {
|
||||
return articles.find({
|
||||
filters: {
|
||||
slug: { $eq: slug },
|
||||
},
|
||||
populate: ['cover', 'author', 'category', 'blocks.file', 'blocks.files'],
|
||||
}) as Promise<TStrapiResponseCollection<TArticle>>
|
||||
}
|
||||
|
||||
// Server Functions - these run on the server and can be called from components
|
||||
|
||||
export const getArticlesData = createServerFn({
|
||||
method: 'GET',
|
||||
})
|
||||
.inputValidator(
|
||||
(input?: { page?: number; category?: string; query?: string }) => input,
|
||||
)
|
||||
.handler(async ({ data }): Promise<TStrapiResponseCollection<TArticle>> => {
|
||||
const response = await getArticles(data?.page, data?.category, data?.query)
|
||||
return response
|
||||
})
|
||||
|
||||
export const getArticleByIdData = createServerFn({
|
||||
method: 'GET',
|
||||
})
|
||||
.inputValidator((documentId: string) => documentId)
|
||||
.handler(
|
||||
async ({ data: documentId }): Promise<TStrapiResponseSingle<TArticle>> => {
|
||||
const response = await getArticleById(documentId)
|
||||
return response
|
||||
},
|
||||
)
|
||||
|
||||
export const getArticleBySlugData = createServerFn({
|
||||
method: 'GET',
|
||||
})
|
||||
.inputValidator((slug: string) => slug)
|
||||
.handler(
|
||||
async ({ data: slug }): Promise<TStrapiResponseCollection<TArticle>> => {
|
||||
const response = await getArticleBySlug(slug)
|
||||
return response
|
||||
},
|
||||
)
|
||||
28
src/data/loaders/index.ts
Normal file
28
src/data/loaders/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
getArticlesData,
|
||||
getArticleByIdData,
|
||||
getArticleBySlugData,
|
||||
} from './articles'
|
||||
|
||||
/**
|
||||
* Strapi API - Server functions for fetching data from Strapi
|
||||
*
|
||||
* Usage in route loaders:
|
||||
* ```ts
|
||||
* import { strapiApi } from "@/data/loaders";
|
||||
*
|
||||
* export const Route = createFileRoute("/articles")({
|
||||
* loader: async () => {
|
||||
* const { data, meta } = await strapiApi.articles.getArticlesData();
|
||||
* return data;
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const strapiApi = {
|
||||
articles: {
|
||||
getArticlesData,
|
||||
getArticleByIdData,
|
||||
getArticleBySlugData,
|
||||
},
|
||||
}
|
||||
199
src/data/portfolio.ts
Normal file
199
src/data/portfolio.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const CONTACT_EMAIL = 'moh682@gmail.com'
|
||||
|
||||
// ── Interfaces ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Experience {
|
||||
readonly id: string
|
||||
readonly company: string
|
||||
readonly role: string
|
||||
readonly type: 'Full-time' | 'Part-time'
|
||||
/** ISO month string e.g. "2022-07" */
|
||||
readonly startDate: string
|
||||
/** ISO month string e.g. "2022-07", or null for current role */
|
||||
readonly endDate: string | null
|
||||
readonly location: string
|
||||
readonly bullets: readonly string[]
|
||||
readonly tech: readonly string[]
|
||||
}
|
||||
|
||||
export interface Education {
|
||||
readonly id: string
|
||||
readonly institution: string
|
||||
readonly degree: string
|
||||
readonly startYear: number
|
||||
readonly endYear: number
|
||||
readonly grade: string | null
|
||||
}
|
||||
|
||||
export interface SkillGroup {
|
||||
readonly category: string
|
||||
readonly skills: readonly string[]
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Formats an ISO month string ("YYYY-MM") to a human-readable label ("Jul 2022").
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
const [yearStr, monthStr] = iso.split('-')
|
||||
const month = parseInt(monthStr, 10)
|
||||
return `${MONTH_NAMES[month - 1]} ${yearStr}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a human-readable duration between two ISO month strings.
|
||||
* If endDate is omitted, the current month is used.
|
||||
*
|
||||
* @example computeDuration("2022-07") // "2yr 10mo"
|
||||
* @example computeDuration("2021-08", "2022-07") // "11mo"
|
||||
*/
|
||||
export function computeDuration(startDate: string, endDate?: string): string {
|
||||
const parseIso = (iso: string): Date => {
|
||||
const [yearStr, monthStr] = iso.split('-')
|
||||
return new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1)
|
||||
}
|
||||
|
||||
const start = parseIso(startDate)
|
||||
const end = endDate ? parseIso(endDate) : new Date()
|
||||
|
||||
const totalMonths =
|
||||
(end.getFullYear() - start.getFullYear()) * 12 +
|
||||
(end.getMonth() - start.getMonth())
|
||||
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const months = totalMonths % 12
|
||||
|
||||
const parts: string[] = []
|
||||
if (years > 0) parts.push(`${years}yr`)
|
||||
if (months > 0) parts.push(`${months}mo`)
|
||||
if (parts.length === 0) parts.push('< 1mo')
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const experiences: readonly Experience[] = [
|
||||
{
|
||||
id: 'awaze',
|
||||
company: 'Awaze',
|
||||
role: 'Software Engineer',
|
||||
type: 'Full-time',
|
||||
startDate: '2022-07',
|
||||
endDate: null,
|
||||
location: 'Copenhagen, Denmark',
|
||||
bullets: [
|
||||
'Design, develop and maintain scalable software solutions.',
|
||||
'Collaborate cross-functionally in agile teams.',
|
||||
'Drive technical improvements and code quality initiatives.',
|
||||
],
|
||||
tech: ['TypeScript', 'React', 'Node.js', 'AWS', 'PostgreSQL'],
|
||||
},
|
||||
{
|
||||
id: 'kiatec',
|
||||
company: 'KIAtec',
|
||||
role: 'Full Stack Engineer',
|
||||
type: 'Full-time',
|
||||
startDate: '2021-08',
|
||||
endDate: '2022-07',
|
||||
location: 'Denmark',
|
||||
bullets: [
|
||||
'Design, develop, test and maintain old and new solutions.',
|
||||
'Breakdown, estimate and refine product requirements.',
|
||||
],
|
||||
tech: [
|
||||
'TypeScript',
|
||||
'React',
|
||||
'Redux',
|
||||
'PHP Laravel',
|
||||
'Golang',
|
||||
'PostgreSQL',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'proactive',
|
||||
company: 'ProActive A/S',
|
||||
role: 'Fullstack Developer',
|
||||
type: 'Part-time',
|
||||
startDate: '2019-10',
|
||||
endDate: '2021-06',
|
||||
location: 'Copenhagen, Denmark',
|
||||
bullets: [
|
||||
'Breakdown, design and develop prototypes.',
|
||||
'Bug fix and root cause analysis.',
|
||||
'Develop unit and acceptance tests.',
|
||||
'Maintain, optimise and improve the application.',
|
||||
],
|
||||
tech: ['TypeScript', 'React', 'MobX', 'SPFX', '.NET Core', 'C#', 'Azure'],
|
||||
},
|
||||
{
|
||||
id: 'cba-tutor',
|
||||
company: 'Copenhagen Business Academy',
|
||||
role: 'Tutor',
|
||||
type: 'Part-time',
|
||||
startDate: '2019-02',
|
||||
endDate: '2019-06',
|
||||
location: 'Copenhagen, Denmark',
|
||||
bullets: [],
|
||||
tech: [],
|
||||
},
|
||||
]
|
||||
|
||||
export const educations: readonly Education[] = [
|
||||
{
|
||||
id: 'bachelor-sd',
|
||||
institution: 'Copenhagen Business Academy',
|
||||
degree: "Bachelor's Degree in Software Development",
|
||||
startYear: 2020,
|
||||
endYear: 2021,
|
||||
grade: null,
|
||||
},
|
||||
{
|
||||
id: 'basc-cs',
|
||||
institution: 'Copenhagen Business Academy',
|
||||
degree: 'Bachelor of Applied Science (BASc), Computer Science',
|
||||
startYear: 2017,
|
||||
endYear: 2020,
|
||||
grade: 'A',
|
||||
},
|
||||
]
|
||||
|
||||
export const skillGroups: readonly SkillGroup[] = [
|
||||
{
|
||||
category: 'Frontend',
|
||||
skills: ['TypeScript', 'React', 'Redux', 'MobX', 'SPFX'],
|
||||
},
|
||||
{
|
||||
category: 'Backend',
|
||||
skills: [
|
||||
'Golang',
|
||||
'Node.js',
|
||||
'PHP Laravel',
|
||||
'PostgreSQL',
|
||||
'.NET Core',
|
||||
'C#',
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Tools & Infrastructure',
|
||||
skills: ['Docker', 'Git', 'CI/CD', 'AWS', 'Azure'],
|
||||
},
|
||||
]
|
||||
9
src/data/strapi-sdk.ts
Normal file
9
src/data/strapi-sdk.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { strapi } from '@strapi/client'
|
||||
|
||||
// Strapi base URL (without /api)
|
||||
const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? 'http://localhost:1337'
|
||||
|
||||
// Initialize the Strapi SDK with /api endpoint
|
||||
const sdk = strapi({ baseURL: new URL('/api', STRAPI_BASE).href })
|
||||
|
||||
export { sdk }
|
||||
29
src/lib/strapi-utils.ts
Normal file
29
src/lib/strapi-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Strapi URL helpers
|
||||
*/
|
||||
|
||||
const DEFAULT_STRAPI_URL = 'http://localhost:1337'
|
||||
|
||||
// Base Strapi URL (without /api)
|
||||
export function getStrapiURL(): string {
|
||||
// Handle SSR where import.meta.env might not be fully available
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_STRAPI_URL) {
|
||||
return import.meta.env.VITE_STRAPI_URL
|
||||
}
|
||||
return DEFAULT_STRAPI_URL
|
||||
}
|
||||
|
||||
// Get full URL for media assets
|
||||
export function getStrapiMedia(url: string | undefined | null): string {
|
||||
if (!url) return ''
|
||||
if (
|
||||
url.startsWith('data:') ||
|
||||
url.startsWith('http') ||
|
||||
url.startsWith('//')
|
||||
) {
|
||||
return url
|
||||
}
|
||||
// Ensure we always have a valid base URL
|
||||
const baseUrl = getStrapiURL() || DEFAULT_STRAPI_URL
|
||||
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`
|
||||
}
|
||||
86
src/routeTree.gen.ts
Normal file
86
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about'
|
||||
id: '__root__' | '/' | '/about'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/about': {
|
||||
id: '/about'
|
||||
path: '/about'
|
||||
fullPath: '/about'
|
||||
preLoaderRoute: typeof AboutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
19
src/router.tsx
Normal file
19
src/router.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter() {
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
scrollRestoration: true,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>
|
||||
}
|
||||
}
|
||||
68
src/routes/__root.tsx
Normal file
68
src/routes/__root.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import Footer from '#/components/Footer'
|
||||
import Header from '#/components/Header'
|
||||
|
||||
import appCss from '../styles.css?url'
|
||||
|
||||
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'Mohammad — Software Engineer',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Mohammad — Software Engineer crafting robust full-stack solutions from Copenhagen, Denmark.',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]">
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
{import.meta.env.DEV && (
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
23
src/routes/about.tsx
Normal file
23
src/routes/about.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/about')({
|
||||
component: About,
|
||||
})
|
||||
|
||||
function About() {
|
||||
return (
|
||||
<main className="page-wrap px-4 py-12">
|
||||
<section className="island-shell rounded-2xl p-6 sm:p-8">
|
||||
<p className="island-kicker mb-2">About</p>
|
||||
<h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl">
|
||||
A small starter with room to grow.
|
||||
</h1>
|
||||
<p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]">
|
||||
TanStack Start gives you type-safe routing, server functions, and
|
||||
modern SSR defaults. Use this as a clean foundation, then layer in
|
||||
your own routes, styling, and add-ons.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
275
src/routes/index.tsx
Normal file
275
src/routes/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
experiences,
|
||||
educations,
|
||||
skillGroups,
|
||||
computeDuration,
|
||||
formatDate,
|
||||
CONTACT_EMAIL,
|
||||
} from '#/data/portfolio'
|
||||
import type { Experience, Education, SkillGroup } from '#/data/portfolio'
|
||||
|
||||
export const Route = createFileRoute('/')({ component: HomePage })
|
||||
|
||||
// ── Shared UI primitives ──────────────────────────────────────────────────
|
||||
|
||||
type ButtonVariant = 'primary' | 'ghost'
|
||||
|
||||
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'rounded-full border border-[rgba(50,143,151,0.35)] bg-[rgba(79,184,178,0.15)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.26)]',
|
||||
ghost:
|
||||
'rounded-full border border-[var(--line)] bg-[var(--surface-strong)] px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.3)]',
|
||||
}
|
||||
|
||||
function ButtonLink({
|
||||
href,
|
||||
variant,
|
||||
children,
|
||||
}: {
|
||||
readonly href: string
|
||||
readonly variant: ButtonVariant
|
||||
readonly children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<a href={href} className={BUTTON_VARIANT_CLASSES[variant]}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function TechChip({ label }: { readonly label: string }) {
|
||||
return (
|
||||
<span className="inline-block rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--lagoon-deep)]">
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Section heading ───────────────────────────────────────────────────────
|
||||
|
||||
function SectionHeading({
|
||||
kicker,
|
||||
title,
|
||||
id,
|
||||
}: {
|
||||
readonly kicker: string
|
||||
readonly title: string
|
||||
readonly id: string
|
||||
}) {
|
||||
return (
|
||||
<div id={id} className="mb-8 scroll-mt-20">
|
||||
<p className="island-kicker mb-2">{kicker}</p>
|
||||
<h2 className="display-title m-0 text-2xl font-bold tracking-tight text-[var(--sea-ink)] sm:text-3xl">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Experience card ───────────────────────────────────────────────────────
|
||||
|
||||
function ExperienceCard({
|
||||
exp,
|
||||
index,
|
||||
}: {
|
||||
readonly exp: Experience
|
||||
readonly index: number
|
||||
}) {
|
||||
const duration = computeDuration(exp.startDate, exp.endDate ?? undefined)
|
||||
const startLabel = formatDate(exp.startDate)
|
||||
const endLabel = exp.endDate ? formatDate(exp.endDate) : 'Present'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rise-in relative flex gap-4 sm:gap-6"
|
||||
style={{ animationDelay: `${index * 80}ms` }}
|
||||
>
|
||||
{/* Timeline dot + connector line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mt-1.5 h-3.5 w-3.5 flex-shrink-0 rounded-full border-2 border-[var(--lagoon)] bg-[var(--lagoon-deep)] shadow-[0_0_0_4px_rgba(79,184,178,0.18)]" />
|
||||
<div className="mt-1.5 w-0.5 flex-1 bg-[linear-gradient(to_bottom,var(--lagoon),transparent)]" />
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="island-shell feature-card mb-6 flex-1 rounded-2xl p-5 pb-6">
|
||||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="island-kicker mb-0.5">{exp.company}</p>
|
||||
<h3 className="m-0 text-base font-bold text-[var(--sea-ink)]">
|
||||
{exp.role}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="mt-0.5 flex-shrink-0 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--sea-ink-soft)]">
|
||||
{exp.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
|
||||
<span>
|
||||
{startLabel} — {endLabel}
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||
<span>{duration}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||
<span>{exp.location}</span>
|
||||
</div>
|
||||
|
||||
{exp.bullets.length > 0 && (
|
||||
<ul className="mb-3 ml-0 list-none space-y-1.5 p-0">
|
||||
{exp.bullets.map((bullet) => (
|
||||
<li
|
||||
key={bullet}
|
||||
className="flex gap-2 text-sm text-[var(--sea-ink-soft)]"
|
||||
>
|
||||
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" />
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{exp.tech.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{exp.tech.map((t) => (
|
||||
<TechChip key={t} label={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Education card ────────────────────────────────────────────────────────
|
||||
|
||||
function EducationCard({ edu }: { readonly edu: Education }) {
|
||||
return (
|
||||
<div className="island-shell feature-card rise-in rounded-2xl p-6">
|
||||
<p className="island-kicker mb-2">{edu.institution}</p>
|
||||
<h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]">
|
||||
{edu.degree}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
|
||||
<span>
|
||||
{edu.startYear} – {edu.endYear}
|
||||
</span>
|
||||
{edu.grade !== null && (
|
||||
<>
|
||||
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||
<span className="font-semibold text-[var(--lagoon-deep)]">
|
||||
Grade: {edu.grade}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skill group card ──────────────────────────────────────────────────────
|
||||
|
||||
function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
|
||||
return (
|
||||
<div className="island-shell feature-card rise-in rounded-2xl p-5">
|
||||
<p className="island-kicker mb-3">{group.category}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.skills.map((skill) => (
|
||||
<TechChip key={skill} label={skill} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<main className="page-wrap px-4 pb-16 pt-10">
|
||||
{/* ── Hero ──────────────────────────────────────────── */}
|
||||
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-12 sm:px-12 sm:py-16">
|
||||
<div className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.3),transparent_65%)]" />
|
||||
<div className="pointer-events-none absolute -bottom-24 -right-24 h-72 w-72 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_65%)]" />
|
||||
<div className="pointer-events-none absolute right-1/3 top-0 h-48 w-48 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.12),transparent_70%)]" />
|
||||
|
||||
<p className="island-kicker mb-4">
|
||||
Software Engineer · Copenhagen, Denmark
|
||||
</p>
|
||||
<h1 className="display-title mb-4 max-w-2xl text-5xl font-bold leading-[1.02] tracking-tight text-[var(--sea-ink)] sm:text-7xl">
|
||||
Hi, I'm Mohammad.
|
||||
</h1>
|
||||
<p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg">
|
||||
Crafting robust full-stack solutions with a passion for clean
|
||||
architecture, great developer experience, and shipping products people
|
||||
love.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ButtonLink href="#experience" variant="primary">
|
||||
View Experience
|
||||
</ButtonLink>
|
||||
<ButtonLink href={`mailto:${CONTACT_EMAIL}`} variant="ghost">
|
||||
Get in Touch
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Experience ────────────────────────────────────── */}
|
||||
<section className="mt-16">
|
||||
<SectionHeading
|
||||
kicker="Career"
|
||||
title="Work Experience"
|
||||
id="experience"
|
||||
/>
|
||||
<div>
|
||||
{experiences.map((exp, i) => (
|
||||
<ExperienceCard key={exp.id} exp={exp} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Education ─────────────────────────────────────── */}
|
||||
<section className="mt-16">
|
||||
<SectionHeading kicker="Background" title="Education" id="education" />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{educations.map((edu) => (
|
||||
<EducationCard key={edu.id} edu={edu} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Skills ────────────────────────────────────────── */}
|
||||
<section className="mt-16">
|
||||
<SectionHeading
|
||||
kicker="Toolkit"
|
||||
title="Skills & Technologies"
|
||||
id="skills"
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skillGroups.map((group) => (
|
||||
<SkillGroupCard key={group.category} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Contact CTA ───────────────────────────────────── */}
|
||||
<section id="contact" className="mt-16 scroll-mt-20">
|
||||
<div className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-12 text-center sm:px-12">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(79,184,178,0.1),transparent_70%)]" />
|
||||
<p className="island-kicker mb-3">Let's connect</p>
|
||||
<h2 className="display-title mb-4 text-2xl font-bold text-[var(--sea-ink)] sm:text-4xl">
|
||||
Open to new opportunities
|
||||
</h2>
|
||||
<p className="mx-auto mb-8 max-w-md text-[var(--sea-ink-soft)]">
|
||||
I'm always interested in hearing about exciting engineering
|
||||
challenges and the teams building them.
|
||||
</p>
|
||||
<ButtonLink href={`mailto:${CONTACT_EMAIL}`} variant="primary">
|
||||
Say hello →
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
259
src/styles.css
Normal file
259
src/styles.css
Normal file
@@ -0,0 +1,259 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sea-ink: #173a40;
|
||||
--sea-ink-soft: #416166;
|
||||
--lagoon: #4fb8b2;
|
||||
--lagoon-deep: #328f97;
|
||||
--palm: #2f6a4a;
|
||||
--sand: #e7f0e8;
|
||||
--foam: #f3faf5;
|
||||
--surface: rgba(255, 255, 255, 0.74);
|
||||
--surface-strong: rgba(255, 255, 255, 0.9);
|
||||
--line: rgba(23, 58, 64, 0.14);
|
||||
--inset-glint: rgba(255, 255, 255, 0.82);
|
||||
--kicker: rgba(47, 106, 74, 0.9);
|
||||
--bg-base: #e7f3ec;
|
||||
--header-bg: rgba(251, 255, 248, 0.84);
|
||||
--chip-bg: rgba(255, 255, 255, 0.8);
|
||||
--chip-line: rgba(47, 106, 74, 0.18);
|
||||
--link-bg-hover: rgba(255, 255, 255, 0.9);
|
||||
--hero-a: rgba(79, 184, 178, 0.36);
|
||||
--hero-b: rgba(47, 106, 74, 0.2);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--sea-ink: #d7ece8;
|
||||
--sea-ink-soft: #afcdc8;
|
||||
--lagoon: #60d7cf;
|
||||
--lagoon-deep: #8de5db;
|
||||
--palm: #6ec89a;
|
||||
--sand: #0f1a1e;
|
||||
--foam: #101d22;
|
||||
--surface: rgba(16, 30, 34, 0.8);
|
||||
--surface-strong: rgba(15, 27, 31, 0.92);
|
||||
--line: rgba(141, 229, 219, 0.18);
|
||||
--inset-glint: rgba(194, 247, 238, 0.14);
|
||||
--kicker: #b8efe5;
|
||||
--bg-base: #0a1418;
|
||||
--header-bg: rgba(10, 20, 24, 0.8);
|
||||
--chip-bg: rgba(13, 28, 32, 0.9);
|
||||
--chip-line: rgba(141, 229, 219, 0.24);
|
||||
--link-bg-hover: rgba(24, 44, 49, 0.8);
|
||||
--hero-a: rgba(96, 215, 207, 0.18);
|
||||
--hero-b: rgba(110, 200, 154, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--sea-ink: #d7ece8;
|
||||
--sea-ink-soft: #afcdc8;
|
||||
--lagoon: #60d7cf;
|
||||
--lagoon-deep: #8de5db;
|
||||
--palm: #6ec89a;
|
||||
--sand: #0f1a1e;
|
||||
--foam: #101d22;
|
||||
--surface: rgba(16, 30, 34, 0.8);
|
||||
--surface-strong: rgba(15, 27, 31, 0.92);
|
||||
--line: rgba(141, 229, 219, 0.18);
|
||||
--inset-glint: rgba(194, 247, 238, 0.14);
|
||||
--kicker: #b8efe5;
|
||||
--bg-base: #0a1418;
|
||||
--header-bg: rgba(10, 20, 24, 0.8);
|
||||
--chip-bg: rgba(13, 28, 32, 0.9);
|
||||
--chip-line: rgba(141, 229, 219, 0.24);
|
||||
--link-bg-hover: rgba(24, 44, 49, 0.8);
|
||||
--hero-a: rgba(96, 215, 207, 0.18);
|
||||
--hero-b: rgba(110, 200, 154, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--sea-ink);
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-base);
|
||||
background:
|
||||
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
|
||||
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
|
||||
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
|
||||
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.28;
|
||||
background:
|
||||
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
|
||||
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
|
||||
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.14;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--lagoon-deep);
|
||||
text-decoration-color: rgba(50, 143, 151, 0.4);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #246f76;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
|
||||
border-radius: 7px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
pre code {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page-wrap {
|
||||
width: min(1080px, calc(100% - 2rem));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.display-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
}
|
||||
|
||||
.island-shell {
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
|
||||
box-shadow:
|
||||
0 1px 0 var(--inset-glint) inset,
|
||||
0 22px 44px rgba(30, 90, 72, 0.1),
|
||||
0 6px 18px rgba(23, 58, 64, 0.08);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
|
||||
box-shadow:
|
||||
0 1px 0 var(--inset-glint) inset,
|
||||
0 18px 34px rgba(30, 90, 72, 0.1),
|
||||
0 4px 14px rgba(23, 58, 64, 0.06);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
|
||||
}
|
||||
|
||||
button,
|
||||
.island-shell,
|
||||
a {
|
||||
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
|
||||
.island-kicker {
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
font-size: 0.69rem;
|
||||
color: var(--kicker);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: var(--sea-ink-soft);
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -6px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
|
||||
transition: transform 170ms ease;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.is-active {
|
||||
color: var(--sea-ink);
|
||||
}
|
||||
|
||||
.nav-link:hover::after,
|
||||
.nav-link.is-active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.nav-link::after {
|
||||
bottom: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
|
||||
}
|
||||
|
||||
.rise-in {
|
||||
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
90
src/types/strapi.ts
Normal file
90
src/types/strapi.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Strapi type definitions
|
||||
* These types match the Strapi Cloud Template Blog schema
|
||||
*/
|
||||
|
||||
import type { Block } from '@/components/blocks'
|
||||
|
||||
// Base image type from Strapi media library
|
||||
export type TImage = {
|
||||
id: number
|
||||
documentId: string
|
||||
alternativeText: string | null
|
||||
url: string
|
||||
}
|
||||
|
||||
// Author content type
|
||||
export type TAuthor = {
|
||||
id: number
|
||||
documentId: string
|
||||
name: string
|
||||
email?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
// Category content type
|
||||
export type TCategory = {
|
||||
id: number
|
||||
documentId: string
|
||||
name: string
|
||||
slug: string
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
// Article content type
|
||||
export type TArticle = {
|
||||
id: number
|
||||
documentId: string
|
||||
title: string
|
||||
description: string
|
||||
slug: string
|
||||
cover?: TImage
|
||||
author?: TAuthor
|
||||
category?: TCategory
|
||||
blocks?: Array<Block>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
// Strapi response wrappers
|
||||
export type TStrapiResponseSingle<T> = {
|
||||
data: T
|
||||
meta?: {
|
||||
pagination?: TStrapiPagination
|
||||
}
|
||||
}
|
||||
|
||||
export type TStrapiResponseCollection<T> = {
|
||||
data: Array<T>
|
||||
meta?: {
|
||||
pagination?: TStrapiPagination
|
||||
}
|
||||
}
|
||||
|
||||
export type TStrapiPagination = {
|
||||
page: number
|
||||
pageSize: number
|
||||
pageCount: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type TStrapiError = {
|
||||
status: number
|
||||
name: string
|
||||
message: string
|
||||
details?: Record<string, Array<string>>
|
||||
}
|
||||
|
||||
export type TStrapiResponse<T = null> = {
|
||||
data?: T
|
||||
error?: TStrapiError
|
||||
meta?: {
|
||||
pagination?: TStrapiPagination
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user