diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..c6b0f27c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,13 @@ +# Dependencies +node_modules + +# Build outputs +dist +build +*.min.js + +# Package files +package-lock.json +yarn.lock +pnpm-lock.yaml + diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..54736c79 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "avoid", + "endOfLine": "lf" +} + diff --git a/README.md b/README.md index 200f4282..1539725c 100644 --- a/README.md +++ b/README.md @@ -1 +1,245 @@ -# Portfolio +# 🌟 Frontend Portfolio Website + +## πŸ”— Live Site + +**[technigo.daniellauding.se](https://technigo.daniellauding.se)** + +> This is my **Technigo frontend development portfolio** showcasing my JavaScript and React skills. + +--- + +## πŸ‘‹ About + +Hi! I'm Daniel, and this is my **Technigo frontend portfolio** where I show off my projects and tell you about myself! + +### Other Portfolios + +- **🎨 Design Portfolio:** [daniellauding.se](https://www.daniellauding.se) - My main design portfolio +- **πŸ“ Recent Projects (Figma):** [View in Figma](https://www.figma.com/proto/ITcLm3ciPq4G5qkKP6q1d9/instinctly-selected-work?page-id=624%3A457&node-id=624-458&p=f&viewport=-9839%2C-1808%2C0.66&t=aXOv3ONToAEWFkFK-1&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=624%3A458) - Recent selected work + +--- + +## 🎯 What This Website Does + +This is like my digital business card! It shows: + +- **Who I am** - A person who loves building websites +- **What I've built** - Cool projects I've made +- **My thoughts** - Things I think about when making websites +- **How to contact me** - So we can chat about making cool stuff together! + +--- + +## 🧸 Why I Built My Own Design Library + +### Like LEGO Blocks! + +You know how LEGO blocks can build anything? I made my own "website LEGO blocks" because: + +### 🎨 It's Like Having My Own Crayon Box + +- Instead of using someone else's colors, I made my own! +- Every button, text, and picture looks exactly how I want +- It's like having a magic crayon that always draws the same shade of blue + +### πŸ”§ Building Blocks That Fit Together + +- I made pieces like ` + )} + + + ) +} \ No newline at end of file diff --git a/src/components/Articles/Articles.styled.ts b/src/components/Articles/Articles.styled.ts new file mode 100644 index 00000000..0e7dd830 --- /dev/null +++ b/src/components/Articles/Articles.styled.ts @@ -0,0 +1,114 @@ +import styled from 'styled-components' +import { Image } from '@/components/Image' + +export const ArticlesContainer = styled.ul` + display: flex; + flex-direction: column; + gap: var(--spacing-xl); + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0; + list-style: none; + + ${props => props.theme.media.desktop} { + grid-template-columns: repeat(2, 1fr); + display: grid; + // padding: 0 var(--spacing-lg); + } +` + +export const ArticleCard = styled.article` + background: transparent; + border-radius: var(--radius-none); + overflow: hidden; + transition: transform 0.3s ease, box-shadow 0.3s ease; + display: flex; + flex-direction: column; + + &:hover { + transform: translateY(-4px); + } +` + +export const ArticleImage = styled.div` + position: relative; + width: 100%; + max-width: 100%; + aspect-ratio: 408 / 280; + overflow: hidden; + border-left: 20px solid var(--section-articles-title-color); + border-bottom: 20px solid var(--section-articles-title-color); + ${props => props.theme.media.desktop} { + // width: 408px; + // max-width: 408px; + } +` + +export const StyledImage = styled(Image)` + width: 100%; + height: 100%; + object-fit: cover; + display: block; +`; + +export const ArticleContent = styled.div` + padding: var(--spacing-none); + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +` + +export const ArticleDate = styled.div` + background: var(--tag-bg-color); + color: var(--tag-text-color); + padding: 0 var(--spacing-xs) 0px var(--spacing-xs); + height: 24px; + border-radius: 0; + font-size: var(--text-md); + font-family: var(--text-font-family); + font-weight: var(--weight-medium); + width: fit-content; + margin-top: var(--spacing-lg); + margin-bottom: -16px; +` + +export const ArticleFooter = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-top: var(--spacing-md); +` + +export const ArticleReadTime = styled.span` + font-family: var(--text-font-family); + font-size: var(--text-sm); + color: var(--section-articles-text-color); + opacity: 0.7; +` + +export const ArticleLink = styled.a` + display: inline-flex; + align-items: center; + gap: var(--spacing-md); + color: var(--text-color); + text-decoration: none; + font-family: var(--text-font-family); + font-size: var(--text-xl); + font-weight: var(--weight-medium); + padding: var(--spacing-xs) var(--spacing-md); + border-radius: 9999px; + transition: opacity 0.2s ease; + background: #fff; + height: 48px; + + &:hover { + opacity: 0.7; + } + + &:focus { + outline: 2px solid var(--section-articles-title-color); + outline-offset: 2px; + } +` \ No newline at end of file diff --git a/src/components/Articles/Articles.tsx b/src/components/Articles/Articles.tsx new file mode 100644 index 00000000..9e2997b0 --- /dev/null +++ b/src/components/Articles/Articles.tsx @@ -0,0 +1,35 @@ +import { Section } from '@/components/Section' +import { ArticleCard } from './ArticleCard' +import { ArticlesProps } from './Articles.types' +import { ArticlesContainer } from './Articles.styled' + +export const Articles = ({ data }: ArticlesProps) => { + if (!data) return null + + const articles = data?.articles + if (!articles || !Array.isArray(articles) || articles.length === 0) return null + + return ( +
+ + {articles.map((article) => ( +
  • + +
  • + ))} +
    +
    + ) +} \ No newline at end of file diff --git a/src/components/Articles/Articles.types.ts b/src/components/Articles/Articles.types.ts new file mode 100644 index 00000000..c6082f3f --- /dev/null +++ b/src/components/Articles/Articles.types.ts @@ -0,0 +1,23 @@ +export interface Article { + id: string + title: string + excerpt: string + image: string + date: string + readTime: string + link: string + tags: string[] +} + +export interface ArticlesData { + articles: Article[] +} + +export interface ArticlesProps { + data: ArticlesData +} + +export interface ArticleCardProps { + article: Article + role?: string +} \ No newline at end of file diff --git a/src/components/Articles/index.ts b/src/components/Articles/index.ts new file mode 100644 index 00000000..ef326053 --- /dev/null +++ b/src/components/Articles/index.ts @@ -0,0 +1,3 @@ +export { Articles } from './Articles' +export { ArticleCard } from './ArticleCard' +export type { Article, ArticlesData, ArticlesProps, ArticleCardProps } from './Articles.types' \ No newline at end of file diff --git a/src/components/Button/Button.styled.ts b/src/components/Button/Button.styled.ts new file mode 100644 index 00000000..ddc0fcb3 --- /dev/null +++ b/src/components/Button/Button.styled.ts @@ -0,0 +1,163 @@ +import styled from 'styled-components' +import { ButtonVariant, ButtonSize } from './Button.types' + +interface StyledButtonProps { + $variant?: ButtonVariant + $size?: ButtonSize + $fullWidth?: boolean + $iconOnly?: boolean +} + +export const StyledButton = styled.button` + font-family: ${props => props.theme.fonts.text}; + font-weight: ${props => props.theme.weights.semibold}; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + text-decoration: none; + border: 2px solid transparent; + + padding: var(--spacing-sm) var(--spacing-md); + font-size: ${props => props.theme.textSizes.md}; + + /* Small */ + ${props => props.$size === 'sm' && ` + padding: 6px 12px; + font-size: ${props.theme.textSizes.sm}; + `} + + /* Large */ + ${props => props.$size === 'lg' && ` + padding: 10px 20px; + font-size: ${props.theme.textSizes.lg}; + `} + + /* Default: primary */ + background: ${props => props.theme.colors.primary}; + color: var(--bg-color); + border-color: ${props => props.theme.colors.primary}; + + &:hover:not(:disabled) { + opacity: 0.9; + } + + /* Secondary variant */ + ${props => props.$variant === 'secondary' && ` + background: var(--text-color); + color: var(--bg-color); + border-color: var(--text-color); + `} + + /* Outline variant */ + ${props => props.$variant === 'outline' && ` + background: transparent; + color: ${props.theme.colors.primary}; + border-color: ${props.theme.colors.primary}; + + &:hover:not(:disabled) { + background: ${props.theme.colors.primary}; + color: var(--bg-color); + } + `} + + /* Ghost variant */ + ${props => props.$variant === 'ghost' && ` + background: transparent; + color: ${props.theme.colors.primary}; + border-color: transparent; + + &:hover:not(:disabled) { + background: var(--card-shadow); + } + `} + + /* Tertiary variant */ + ${props => props.$variant === 'tertiary' && ` + background: var(--color-tertiary); + color: var(--text-color); + border-color: transparent; + border-radius: 999px; + + [data-theme="dark"] & { + background: ${props.theme.colors.primary}; + color: #fff; + svg { + color: #fff; + } + } + + &:hover:not(:disabled) { + background: var(--color-primary); + color: #fff; + svg { + color: #fff; + } + } + `} + + ${props => props.$fullWidth && ` + width: 100%; + `} + + ${props => props.$iconOnly && ` + padding: var(--spacing-sm); + aspect-ratio: 1; + width: 48px; + height: 48px; + border-radius: 999px; + `} + + /* === STATES === */ + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:focus-visible { + outline: 2px solid ${props => props.theme.colors.primary}; + outline-offset: 2px; + } + + /* === ICON STYLING === */ + .button__icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + svg, img { + width: auto; + height: 1em; + max-width: 100%; + max-height: 100%; + } + + &[style*="width"], &[style*="height"] { + width: 100%; + height: 100%; + + svg, img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + } + + ${props => props.$iconOnly && ` + .button__icon { + width: 100%; + height: 100%; + + svg, img { + width: 100%; + height: 100%; + object-fit: contain; + } + } + `} +` diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx new file mode 100644 index 00000000..4d37f4f3 --- /dev/null +++ b/src/components/Button/Button.tsx @@ -0,0 +1,122 @@ +import { FC } from 'react' +import { StyledButton } from './Button.styled' +import { ButtonProps } from './Button.types' +import { Icon } from '@/components/Icon' + +export const Button: FC = ({ + children, + variant = 'primary', + size = 'md', + href, + onClick, + disabled = false, + fullWidth = false, + icon, + iconOnly = false, + target, + className = '', + ariaLabel, + ariaDescribedBy, + loading = false, + style, + rel, + iconColor +}) => { + // BEM classname + const bemClass = `button button--${variant} button--${size} ${fullWidth ? 'button--full' : ''} ${iconOnly ? 'button--icon-only' : ''}`.trim() + const fullClassName = `${bemClass} ${className}`.trim() + + const renderIcon = () => { + if (!icon) return null + + const iconStyle = style && (style.width || style.height) + ? { width: style.width, height: style.height } + : undefined + + const getIconSize = () => { + if (style?.width && style?.height) { + const width = typeof style.width === 'string' ? parseInt(style.width) : style.width + const height = typeof style.height === 'string' ? parseInt(style.height) : style.height + const minSize = Math.min(width, height) + return `${minSize * 0.6}px` + } + return undefined + } + + if (typeof icon === 'string') { + return ( + + + + ) + } + + return ( + + {icon} + + ) + } + + const content = ( + <> + {renderIcon()} + {!iconOnly && {children}} + + ) + + if (href) { + const accessibleLabel = iconOnly && !ariaLabel + ? (typeof icon === 'string' ? `${icon} link` : 'Link') + : ariaLabel + + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} diff --git a/src/components/Button/Button.types.ts b/src/components/Button/Button.types.ts new file mode 100644 index 00000000..34de97b0 --- /dev/null +++ b/src/components/Button/Button.types.ts @@ -0,0 +1,29 @@ +import { ReactNode, MouseEvent, CSSProperties } from 'react' + +export type ButtonSize = 'sm' | 'md' | 'lg' + +export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'tertiary' + +export type ButtonTarget = '_blank' | '_self' | '_parent' | '_top' + +export type ButtonRel = 'noopener' | 'noreferrer' | 'nofollow' | 'noopener noreferrer' + +export interface ButtonProps { + children?: ReactNode + variant?: ButtonVariant + size?: ButtonSize + href?: string + onClick?: (e: MouseEvent) => void + disabled?: boolean + fullWidth?: boolean + icon?: ReactNode | string + iconOnly?: boolean + target?: ButtonTarget | string + className?: string + ariaLabel?: string + ariaDescribedBy?: string + loading?: boolean + rel?: ButtonRel | string + iconColor?: string + style?: CSSProperties +} diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts new file mode 100644 index 00000000..7c21e618 --- /dev/null +++ b/src/components/Button/index.ts @@ -0,0 +1,2 @@ +export { Button } from './Button' +export type { ButtonProps, ButtonSize, ButtonVariant } from './Button.types' diff --git a/src/components/CV/CV.styled.ts b/src/components/CV/CV.styled.ts new file mode 100644 index 00000000..f2697319 --- /dev/null +++ b/src/components/CV/CV.styled.ts @@ -0,0 +1,110 @@ +import styled from 'styled-components' + +export const CVContainer = styled.div` + margin: 0 auto; + // padding: 0 var(--spacing-lg); + + .cv__title { + margin-top: var(--spacing-xxl); + margin-bottom: var(--spacing-lg); + color: var(--title-color); + + &:first-child { + margin-top: 0; + } + } + + .cv__company-link, + .cv__school-link { + color: inherit; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + transition: all 0.2s ease; + border-radius: var(--radius-xs); + position: relative; + + &:hover { + color: ${props => props.theme.colors.primary}; + text-decoration: underline; + text-decoration-color: ${props => props.theme.colors.primary}; + text-underline-offset: 3px; + } + + &:focus-visible { + outline: 2px solid ${props => props.theme.colors.primary}; + outline-offset: 2px; + } + + &::after { + content: 'β†—'; + font-size: 0.75em; + opacity: 0.6; + margin-left: 2px; + transition: all 0.2s ease; + } + + &:hover::after { + opacity: 1; + transform: translateX(1px) translateY(-1px); + } + } +` + +export const ExperienceList = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +` + +export const ExperienceItem = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-xs); + padding-bottom: var(--spacing-xl); + + ${props => props.theme.media.desktop} { + grid-template-columns: 180px 1fr; + grid-column-gap: var(--spacing-huge); + } +` + + +export const EducationList = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-lg); +` + +export const EducationItem = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-xs); + padding-bottom: var(--spacing-xl); + + ${props => props.theme.media.desktop} { + grid-template-columns: 180px 1fr; + grid-column-gap: var(--spacing-huge); + } +` + + +export const LinksContainer = styled.div` + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); + + ${props => props.theme.media.tablet} { + flex-direction: row; + gap: var(--spacing-xl); + } +` + + +export const ButtonContainer = styled.div` + display: flex; + justify-content: center; + margin: var(--spacing-lg) 0; +` \ No newline at end of file diff --git a/src/components/CV/CV.tsx b/src/components/CV/CV.tsx new file mode 100644 index 00000000..0d4eb7c7 --- /dev/null +++ b/src/components/CV/CV.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { Section } from '@/components/Section' +import { Title, Text } from '@/components/Typography' +import { Button } from '@/components/Button' +import { + CVContainer, + ExperienceList, + ExperienceItem, + EducationList, + EducationItem, + LinksContainer, + ButtonContainer +} from './CV.styled' +import cvData from '@/data/cv.json' + +export const CV = () => { + const [showAllExperience, setShowAllExperience] = useState(false) + const [showAllEducation, setShowAllEducation] = useState(false) + + const visibleExperience = showAllExperience ? cvData.experience : cvData.experience.slice(0, 3) + const visibleEducation = showAllEducation ? cvData.education : cvData.education.slice(0, 3) + + return ( +
    + + Experience + + + {visibleExperience.map((job: any, index) => ( + + {job.companyUrl ? ( + + <a + href={job.companyUrl} + target="_blank" + rel="noopener noreferrer" + className="cv__company-link" + aria-label={`Visit ${job.company} website (opens in new tab)`} + > + {job.company} + </a> + + ) : ( + {job.company} + )} + {job.period} + {job.role} + {job.description} + + ))} + + + {cvData.experience.length > 3 && ( + + + + )} + + Education + + + {visibleEducation.map((edu: any, index) => ( + + {edu.schoolUrl ? ( + + <a + href={edu.schoolUrl} + target="_blank" + rel="noopener noreferrer" + className="cv__school-link" + aria-label={`Visit ${edu.school} website (opens in new tab)`} + > + {edu.school} + </a> + + ) : ( + {edu.school} + )} + {edu.period} + {edu.program} + {edu.description} + + ))} + + + {cvData.education.length > 3 && ( + + + + )} + + Links + + + + + + + +
    + ) +} \ No newline at end of file diff --git a/src/components/CV/index.ts b/src/components/CV/index.ts new file mode 100644 index 00000000..7f0c936e --- /dev/null +++ b/src/components/CV/index.ts @@ -0,0 +1 @@ +export { CV } from './CV' \ No newline at end of file diff --git a/src/components/Card/Card.styled.ts b/src/components/Card/Card.styled.ts new file mode 100644 index 00000000..c788a5d2 --- /dev/null +++ b/src/components/Card/Card.styled.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components' + +export const StyledCard = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +` + +export const TagsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +` + +export const Tag = styled.span` + padding: 0.25rem 0.75rem; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 1rem; + font-size: 0.875rem; +` + +export const LinksContainer = styled.div` + display: flex; + gap: 1rem; + margin-top: 0.5rem; +` \ No newline at end of file diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx new file mode 100644 index 00000000..cb4191bc --- /dev/null +++ b/src/components/Card/Card.tsx @@ -0,0 +1,29 @@ +import { Title, Text } from '@/components/Typography' +import { Image } from '@/components/Image' +import { CardProps } from './Card.types' +import { StyledCard, TagsContainer, Tag, LinksContainer } from './Card.styled' + +export const Card = ({ image, title, desc, tags, netlify, github, role }: CardProps) => { + return ( + + {image && Thumbnail} + {title && {title}} + {desc && {desc}} + + {tags && ( + + {tags.map(tag => ( + {tag} + ))} + + )} + + {(netlify || github) && ( + + {netlify && {netlify}} + {github && {github}} + + )} + + ) +} \ No newline at end of file diff --git a/src/components/Card/Card.types.ts b/src/components/Card/Card.types.ts new file mode 100644 index 00000000..e9924b47 --- /dev/null +++ b/src/components/Card/Card.types.ts @@ -0,0 +1,9 @@ +export interface CardProps { + image?: string + title?: string + desc?: string + tags?: string[] + netlify?: string + github?: string + role?: string +} \ No newline at end of file diff --git a/src/components/Card/index.ts b/src/components/Card/index.ts new file mode 100644 index 00000000..9ce677a3 --- /dev/null +++ b/src/components/Card/index.ts @@ -0,0 +1,2 @@ +export { Card } from './Card' +export type { CardProps } from './Card.types' diff --git a/src/components/Footer/Footer.styled.ts b/src/components/Footer/Footer.styled.ts new file mode 100644 index 00000000..35b4278a --- /dev/null +++ b/src/components/Footer/Footer.styled.ts @@ -0,0 +1,62 @@ +import styled from 'styled-components' + +export const ContactInfo = styled.div` + margin-bottom: var(--spacing-xl); +` + +export const ContactLink = styled.a` + color: var(--text-color); + text-decoration: none; + + &:hover { + opacity: 0.7; + } + + &:focus { + outline: 2px solid var(--title-color); + outline-offset: 2px; + } +` + +export const SocialLinks = styled.ul` + display: flex; + justify-content: center; + gap: var(--spacing-lg); + margin-bottom: var(--spacing-xl); + color: var(--color-icon); + list-style: none; + margin: 0; + padding: 0; +` + +export const FooterMarquee = styled.div` + background: var(--bg-marquee); + color: var(--bg-color); + padding: var(--spacing-md) 0; + overflow: hidden; + white-space: nowrap; + position: relative; +` + +export const MarqueeTrack = styled.div` + display: flex; + animation: marquee 30s linear infinite; + + @keyframes marquee { + 0% { + transform: translateX(0%); + } + 100% { + transform: translateX(-50%); + } + } +` + +export const MarqueeText = styled.div` + display: inline-block; + font-family: var(--text-font-family); + font-size: var(--text-lg); + font-weight: var(--weight-medium); + padding-right: 50px; + flex-shrink: 0; +` \ No newline at end of file diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000..a05d8e7a --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,121 @@ +import { Avatar } from '@/components/Image' +import { Text, Title } from '@/components/Typography' +import { useTheme } from '@/contexts/ThemeContext' +import { Section } from '@/components/Section' +import { FooterProps } from './Footer.types' +import { + ContactInfo, + ContactLink, + SocialLinks, + FooterMarquee, + MarqueeTrack, + MarqueeText +} from './Footer.styled' +import { Button } from '../Button' + +export const SOCIAL_ICON_MAP: Record = { + linkedin: "LinkedIn", + github: "Github", + twitter: "Twitter", + instagram: "Instagram", + stackoverflow: "StackOverflow" +} + +export const Footer = ({ data }: FooterProps) => { + if (!data) return null + + const { theme } = useTheme() + + const { name, phone, email, avatar_url, socialLinks } = data + + const hasContent = avatar_url || name || phone || email || socialLinks + if (!hasContent) return null + + return ( + <> + + + + + ) +} \ No newline at end of file diff --git a/src/components/Footer/Footer.types.ts b/src/components/Footer/Footer.types.ts new file mode 100644 index 00000000..5f14591b --- /dev/null +++ b/src/components/Footer/Footer.types.ts @@ -0,0 +1,18 @@ +export interface FooterData { + name?: string + title?: string + phone?: string + email?: string + avatar_url?: string + socialLinks?: { + linkedin?: string + github?: string + twitter?: string + stackoverflow?: string + instagram?: string + } +} + +export interface FooterProps { + data?: FooterData +} \ No newline at end of file diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000..0271f43e --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1,2 @@ +export { Footer } from './Footer' +export type { FooterData, FooterProps } from './Footer.types' \ No newline at end of file diff --git a/src/components/Grid/Grid.styled.ts b/src/components/Grid/Grid.styled.ts new file mode 100644 index 00000000..8f5ca46a --- /dev/null +++ b/src/components/Grid/Grid.styled.ts @@ -0,0 +1,20 @@ +import styled from 'styled-components' + +type StyledProps = { + $columns?: string + $gap?: string +} + +export const StyledGrid = styled.div` + display: grid; + width: 100%; + + gap: var(--spacing-${props => props.$gap || 'md'}); + + grid-template-columns: 1fr; + + ${props => props.theme.media.tablet} { + grid-template-columns: repeat(${props => props.$columns || '1'}, 1fr); + } +` + diff --git a/src/components/Grid/Grid.tsx b/src/components/Grid/Grid.tsx new file mode 100644 index 00000000..8111ae23 --- /dev/null +++ b/src/components/Grid/Grid.tsx @@ -0,0 +1,15 @@ +import { StyledGrid } from './Grid.styled' +import { GridProps } from './Grid.types' + +export const Grid = ({ children, columns = '1', gap = 'md', className = '' }: GridProps) => { + // BEM classname + const bemClass = `grid grid--${columns}-col grid--gap-${gap}` + const fullClassName = `${bemClass} ${className}`.trim() + + return ( + + {children} + + ) +} + diff --git a/src/components/Grid/Grid.types.ts b/src/components/Grid/Grid.types.ts new file mode 100644 index 00000000..268287f5 --- /dev/null +++ b/src/components/Grid/Grid.types.ts @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +export type GridColumns = '1' | '2' | '3' | '4' | '6' | '12' + +export type GridGap = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + +export type GridProps = { + children: ReactNode + columns?: GridColumns + gap?: GridGap + className?: string +} + diff --git a/src/components/Grid/GridItem.tsx b/src/components/Grid/GridItem.tsx new file mode 100644 index 00000000..a58635cf --- /dev/null +++ b/src/components/Grid/GridItem.tsx @@ -0,0 +1,27 @@ +import styled from 'styled-components' + +type GridItemProps = { + span?: string + className?: string + children: React.ReactNode +} + +const StyledGridItem = styled.div<{ $span?: string }>` + + ${props => props.$span === 'full' && `grid-column: 1 / -1;`} + ${props => props.$span === '2' && `grid-column: span 2;`} + ${props => props.$span === '3' && `grid-column: span 3;`} + ${props => props.$span === '4' && `grid-column: span 4;`} +` + +export const GridItem = ({ span, className = '', children }: GridItemProps) => { + const bemClass = `grid__item ${span ? `grid__item--span-${span}` : ''}` + const fullClassName = `${bemClass} ${className}`.trim() + + return ( + + {children} + + ) +} + diff --git a/src/components/Grid/index.ts b/src/components/Grid/index.ts new file mode 100644 index 00000000..8b271c22 --- /dev/null +++ b/src/components/Grid/index.ts @@ -0,0 +1,5 @@ +export { Grid } from './Grid' +export { GridItem } from './GridItem' +export type { GridProps, GridColumns, GridGap } from './Grid.types' + + diff --git a/src/components/Hero/Hero.styled.ts b/src/components/Hero/Hero.styled.ts new file mode 100644 index 00000000..7bf17a29 --- /dev/null +++ b/src/components/Hero/Hero.styled.ts @@ -0,0 +1,57 @@ +import styled from 'styled-components' + +export const HeroGrid = styled.div` + display: grid; + width: 100%; + gap: var(--spacing-xs); + + grid-template-columns: 1fr; + grid-template-areas: + "avatar" + "intro" + "role" + "desc"; + + ${props => props.theme.media.tablet} { + grid-template-columns: minmax(0, auto) 1fr; + grid-template-areas: + "intro intro" + "role role" + "avatar desc"; + } + + ${props => props.theme.media.desktop} { + grid-template-columns: minmax(0, auto) 1fr; + grid-template-areas: + "intro intro" + "role role" + "avatar desc"; + } +` + +export const HeroAvatar = styled.div` + grid-area: avatar; + margin-left: auto; + margin-right: auto; + ${props => props.theme.media.tablet} { + margin-right: var(--spacing-md); + } +` + +export const HeroIntro = styled.div` + grid-area: intro; +` + +export const HeroRole = styled.div` + grid-area: role; + margin-bottom: var(--spacing-md); +` + +export const HeroDesc = styled.div` + grid-area: desc; + gap: var(--spacing-lg); + display: flex; + flex-direction: column; +` + + diff --git a/src/components/Hero/Hero.tsx b/src/components/Hero/Hero.tsx new file mode 100644 index 00000000..1d6a283a --- /dev/null +++ b/src/components/Hero/Hero.tsx @@ -0,0 +1,115 @@ +import { Section } from '@/components/Section' +import { Button } from '@/components/Button' +import { Avatar } from '@/components/Image' +import { Text, Title } from '@/components/Typography' +import { useTheme } from '@/contexts/ThemeContext' +import { HeroProps } from './Hero.types' +import { + HeroGrid, + HeroAvatar, + HeroIntro, + HeroRole, + HeroDesc, +} from './Hero.styled' + +export const Hero = ({ data }: HeroProps) => { + if (!data) return null + + const { theme } = useTheme() + const { intro, name, role, desc, avatar_url } = data + + const hasContent = intro || name || role || desc || avatar_url + if (!hasContent) return null + + return ( +
    + + +
    + ) +} diff --git a/src/components/Hero/Hero.types.ts b/src/components/Hero/Hero.types.ts new file mode 100644 index 00000000..4ac8d8d6 --- /dev/null +++ b/src/components/Hero/Hero.types.ts @@ -0,0 +1,11 @@ +export type HeroData = { + intro?: string + name?: string + role?: string + desc?: string | string[] + avatar_url?: string +} + +export type HeroProps = { + data: HeroData +} diff --git a/src/components/Hero/index.ts b/src/components/Hero/index.ts new file mode 100644 index 00000000..31d5ae5d --- /dev/null +++ b/src/components/Hero/index.ts @@ -0,0 +1,2 @@ +export { Hero } from './Hero' +export type { HeroProps, HeroData } from './Hero.types' diff --git a/src/components/Icon/Icon.styled.ts b/src/components/Icon/Icon.styled.ts new file mode 100644 index 00000000..a864c918 --- /dev/null +++ b/src/components/Icon/Icon.styled.ts @@ -0,0 +1,38 @@ +import styled from 'styled-components' + +export const StyledIcon = styled.div<{ + $size?: string + $customSize?: string + $color?: string +}>` + display: flex; + align-items: center; + justify-content: center; + + width: ${props => { + if (props.$size === 's') return '16px' + if (props.$size === 'l') return '32px' + return '24px' + }}; + + height: ${props => { + if (props.$size === 's') return '16px' + if (props.$size === 'l') return '32px' + return '24px' + }}; + + ${props => + props.$customSize && + ` + width: ${props.$customSize}; + height: ${props.$customSize}; + `} + + color: ${props => props.$color || 'inherit'}; + + svg { + width: 100%; + height: 100%; + display: block; + } +` \ No newline at end of file diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx new file mode 100644 index 00000000..ea6c5179 --- /dev/null +++ b/src/components/Icon/Icon.tsx @@ -0,0 +1,37 @@ +import icons from '@/data/icons.json' +import { IconProps } from './Icon.types' +import { StyledIcon } from './Icon.styled' + +export const Icon = ({ + name, + size = 'm', + customSize, + color, + decorative = false, + title, + ariaLabel, + ...props +}: IconProps) => { + const iconSvg = (icons as Record)[name] + + if (!iconSvg) { + if (import.meta.env.DEV) { + console.warn(`Icon "${name}" not found`) + } + return null + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/Icon/Icon.types.ts b/src/components/Icon/Icon.types.ts new file mode 100644 index 00000000..3ca991b8 --- /dev/null +++ b/src/components/Icon/Icon.types.ts @@ -0,0 +1,10 @@ +export interface IconProps { + name: string + size?: 's' | 'm' | 'l' + customSize?: string + color?: string + [key: string]: unknown + decorative?: boolean; + title?: string; + ariaLabel?: string; +} \ No newline at end of file diff --git a/src/components/Icon/index.ts b/src/components/Icon/index.ts new file mode 100644 index 00000000..1f6c0ce0 --- /dev/null +++ b/src/components/Icon/index.ts @@ -0,0 +1 @@ +export { Icon } from './Icon.tsx' diff --git a/src/components/Image/Avatar.styled.js b/src/components/Image/Avatar.styled.js new file mode 100644 index 00000000..4b58274b --- /dev/null +++ b/src/components/Image/Avatar.styled.js @@ -0,0 +1,8 @@ +import styled from 'styled-components' + +export const StyledAvatar = styled.img` + width: ${({ width }) => width || '200px'}; + height: ${({ height }) => height || '200px'}; + border-radius: 50%; + object-fit: cover; +` diff --git a/src/components/Image/Avatar.tsx b/src/components/Image/Avatar.tsx new file mode 100644 index 00000000..887180b6 --- /dev/null +++ b/src/components/Image/Avatar.tsx @@ -0,0 +1,21 @@ +import { StyledAvatar } from './Avatar.styled' + +type AvatarProps = { + src?: string + alt?: string + width?: string + height?: string + className?: string + role?: string + 'aria-label'?: string + [key: string]: unknown + decorative?: boolean; + loading?: 'lazy' | 'eager'; + fetchPriority?: 'high' | 'low' | 'auto'; +} + +const Avatar = ({ src, alt = 'Avatar', width, height, decorative = false, loading = 'lazy', fetchPriority, ...props }: AvatarProps) => { + return +} + +export { Avatar } diff --git a/src/components/Image/Image.tsx b/src/components/Image/Image.tsx new file mode 100644 index 00000000..11e276f5 --- /dev/null +++ b/src/components/Image/Image.tsx @@ -0,0 +1,25 @@ +interface ImageProps { + src: string + alt?: string + size?: string | number + width?: string | number + height?: string | number + [key: string]: unknown + decorative?: boolean; + loading?: 'lazy' | 'eager'; + fetchPriority?: 'high' | 'low' | 'auto'; +} + +export const Image = ({ src, alt, size, width, height, decorative = false, loading = 'lazy', fetchPriority, ...props }: ImageProps) => { + return ( + {decorative + ) +} \ No newline at end of file diff --git a/src/components/Image/index.js b/src/components/Image/index.js new file mode 100644 index 00000000..79f82730 --- /dev/null +++ b/src/components/Image/index.js @@ -0,0 +1,2 @@ +export { Image } from './Image.tsx' +export { Avatar } from './Avatar.tsx' diff --git a/src/components/Projects/ProjectCard.tsx b/src/components/Projects/ProjectCard.tsx new file mode 100644 index 00000000..a67199fe --- /dev/null +++ b/src/components/Projects/ProjectCard.tsx @@ -0,0 +1,137 @@ +import { Text, Title } from '@/components/Typography' +import { Tag } from '@/components/Tag/Tag' +import { Button } from '@/components/Button' +import { useTheme } from '@/contexts/ThemeContext' +import { ProjectCardProps } from './Projects.types' +import { + ProjectCard as Card, + ProjectImage, + StyledImage, + ProjectContent, + ProjectTags, + ProjectActions, + ProjectDateBadge +} from './Projects.styled' + +export const ProjectCard = ({ project, ...props }: ProjectCardProps) => { + if (!project) return null + + const { theme } = useTheme() + + const { name, description, image, tags, netlify, github, date, link, codepen } = project + + const hasContent = name || description || image + if (!hasContent) return null + + return ( + + {image && ( + + + + )} + + {date && ( + + + {date} + + + )} + {name && ( + + {name} + + )} + {description && ( + + {description} + + )} + {tags && tags.length > 0 && ( + + {tags.map((tag, index) => ( + + {tag} + + ))} + + )} + {(netlify || github || link || codepen) && ( + + {netlify && ( + + )} + {github && ( + + )} + {link && ( + + )} + {codepen && ( + + )} + + )} + + + ) +} \ No newline at end of file diff --git a/src/components/Projects/Projects.styled.ts b/src/components/Projects/Projects.styled.ts new file mode 100644 index 00000000..c675cc9f --- /dev/null +++ b/src/components/Projects/Projects.styled.ts @@ -0,0 +1,135 @@ +import styled from 'styled-components' +import { Image } from '@/components/Image' + +export const ProjectsContainer = styled.ul` + display: flex; + flex-direction: column; + gap: var(--spacing-xl); + width: 100%; + max-width: 1200px; + padding: 0 0; + margin: var(--spacing-md) auto 0 auto; + + ${props => props.theme.media.tablet} { + } + + ${props => props.theme.media.desktop} { + margin: var(--spacing-huge) auto 0 auto; + } +` + +export const TabsContainer = styled.div` + display: flex; + gap: var(--spacing-md); + margin-bottom: var(--spacing-md); + flex-wrap: wrap; +` + +export const ProjectDateBadge = styled.span` + padding: 0px; + width: fit-content; +` + +export const ProjectCard = styled.article` + background: var(--card-bg); + border-radius: var(--radius-none); + overflow: hidden; + transition: transform 0.3s ease, box-shadow 0.3s ease; + display: flex; + flex-direction: column; + ${props => props.theme.media.desktop} { + flex-direction: row; + } +` + +export const ProjectImage = styled.div` + position: relative; + width: 100%; + max-width: 100%; + aspect-ratio: 408 / 280; + overflow: hidden; + border-left: 20px solid #E6DAC0; + border-bottom: 20px solid #E6DAC0; + ${props => props.theme.media.desktop} { + width: 408px; + max-width: 408px; + } +` + +export const StyledImage = styled(Image)` + width: 100%; + height: 100%; + object-fit: cover; + display: block; +`; + +export const ProjectContent = styled.div` + padding: var(--spacing-lg) 0; + flex: 1; + gap: var(--spacing-md); + display: flex; + flex-direction: column; + ${props => props.theme.media.desktop} { + padding: 0 var(--spacing-xl); + } +` + +export const ProjectTags = styled.div` + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + margin-bottom: var(--spacing-lg); +` + +export const ProjectTag = styled.span` + background: var(--text-color); + color: var(--bg-color); + padding: var(--spacing-xs) var(--spacing-xs); + border-radius: var(--radius-none); + font-size: var(--text-sm); + font-family: var(--text-font-family); +` + +export const ProjectActions = styled.div` + display: flex; + gap: var(--spacing-md); + padding-top: var(--spacing-md); +` + +export const ProjectLink = styled.a` + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + color: var(--title-color); + text-decoration: none; + font-family: var(--text-font-family); + font-size: var(--text-md); + font-weight: var(--weight-medium); + transition: opacity 0.2s ease, outline 0.2s ease; + border-radius: var(--radius-sm); + padding: var(--spacing-xs); + margin: calc(-1 * var(--spacing-xs)); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + opacity: 0.7; + } + + &:focus { + outline: 2px solid var(--title-color); + outline-offset: 2px; + } + + &:focus:not(:focus-visible) { + outline: none; + } + + &:focus-visible { + outline: 2px solid var(--title-color); + outline-offset: 2px; + } +` \ No newline at end of file diff --git a/src/components/Projects/Projects.tsx b/src/components/Projects/Projects.tsx new file mode 100644 index 00000000..c7f4a280 --- /dev/null +++ b/src/components/Projects/Projects.tsx @@ -0,0 +1,86 @@ +import { Title } from '@/components/Typography' +import { Section } from '@/components/Section' +import { ProjectCard } from './ProjectCard' +import { ProjectsProps } from './Projects.types' +import { ProjectsContainer, TabsContainer } from './Projects.styled' +import { CV } from '@/components/CV' +import { Tag } from '@/components/Tag' +import { useState } from 'react' + +export const Projects = ({ data }: ProjectsProps) => { + const [activeTab, setActiveTab] = useState<'projects' | 'cv'>('projects') + + if (!data) return null + + const projects = data?.projects + if (!projects || !Array.isArray(projects) || projects.length === 0) return null + + const getActiveTitle = () => { + return activeTab === 'projects' ? 'Featured Projects' : 'Experience & Education' + } + + const titleId = 'projects-title' + + return ( +
    + + setActiveTab('projects')} + selected={activeTab === 'projects'} + role="tab" + aria-selected={activeTab === 'projects'} + aria-controls="projects-content" + variant="chip" + className="section__tab section__tab--projects" + > + Featured Projects + + setActiveTab('cv')} + selected={activeTab === 'cv'} + role="tab" + aria-selected={activeTab === 'cv'} + aria-controls="cv-content" + variant="chip" + className="section__tab section__tab--cv" + > + Experience & Education + + + + + {getActiveTitle()} + + + {activeTab === 'projects' && ( + + {projects.map((project, index) => ( +
  • + +
  • + ))} +
    + )} + + {activeTab === 'cv' && ( +
    + +
    + )} +
    + ) +} \ No newline at end of file diff --git a/src/components/Projects/Projects.types.ts b/src/components/Projects/Projects.types.ts new file mode 100644 index 00000000..ee07ccdb --- /dev/null +++ b/src/components/Projects/Projects.types.ts @@ -0,0 +1,23 @@ +export interface Project { + name: string + description: string + image: string + tags: string[] + netlify: string + github: string + date?: string + codepen?: string + link?: string +} + +export interface ProjectsData { + projects: Project[] +} + +export interface ProjectsProps { + data: ProjectsData +} + +export interface ProjectCardProps { + project: Project +} \ No newline at end of file diff --git a/src/components/Projects/index.js b/src/components/Projects/index.js new file mode 100644 index 00000000..5c0ad7f2 --- /dev/null +++ b/src/components/Projects/index.js @@ -0,0 +1,2 @@ +export { Projects } from './Projects' +export { ProjectCard } from './ProjectCard' diff --git a/src/components/Projects/index.ts b/src/components/Projects/index.ts new file mode 100644 index 00000000..9f70e776 --- /dev/null +++ b/src/components/Projects/index.ts @@ -0,0 +1,3 @@ +export { Projects } from './Projects' +export { ProjectCard } from './ProjectCard' +export type { Project, ProjectsData, ProjectsProps, ProjectCardProps } from './Projects.types' \ No newline at end of file diff --git a/src/components/ScrollIndicator/ScrollIndicator.styled.ts b/src/components/ScrollIndicator/ScrollIndicator.styled.ts new file mode 100644 index 00000000..5b313eee --- /dev/null +++ b/src/components/ScrollIndicator/ScrollIndicator.styled.ts @@ -0,0 +1,36 @@ +import styled from 'styled-components' + +interface StyledScrollIndicatorProps { + $color?: string + $height?: string + $position?: 'top' | 'bottom' +} + +export const StyledScrollIndicator = styled.div` + position: fixed; + ${props => props.$position === 'bottom' ? 'bottom: 0;' : 'top: 0;'} + left: 0; + right: 0; + height: ${props => props.$height || '4px'}; + background-color: ${props => props.$color || props.theme.colors.primary}; + z-index: 9999; + transform-origin: 0% 50%; + + animation: scroll-progress linear; + animation-timeline: scroll(); + animation-duration: 1ms; + + @keyframes scroll-progress { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } + } + + @supports not (animation-timeline: scroll()) { + transform: scaleX(var(--scroll-progress, 0)); + transition: transform 0.1s ease-out; + } +` \ No newline at end of file diff --git a/src/components/ScrollIndicator/ScrollIndicator.tsx b/src/components/ScrollIndicator/ScrollIndicator.tsx new file mode 100644 index 00000000..a127107b --- /dev/null +++ b/src/components/ScrollIndicator/ScrollIndicator.tsx @@ -0,0 +1,51 @@ +import { FC, useEffect, useRef } from 'react' +import { StyledScrollIndicator } from './ScrollIndicator.styled' +import { ScrollIndicatorProps } from './ScrollIndicator.types' + +export const ScrollIndicator: FC = ({ + className = '', + color, + height = '4px', + position = 'top' +}) => { + const indicatorRef = useRef(null) + + useEffect(() => { + const updateScrollProgress = () => { + if (!indicatorRef.current) return + + const scrollTop = window.pageYOffset || document.documentElement.scrollTop + const scrollHeight = document.documentElement.scrollHeight - window.innerHeight + const scrollProgress = scrollHeight > 0 ? scrollTop / scrollHeight : 0 + + indicatorRef.current.style.setProperty('--scroll-progress', scrollProgress.toString()) + } + + const supportsScrollTimeline = CSS.supports('animation-timeline: scroll()') + + if (!supportsScrollTimeline) { + window.addEventListener('scroll', updateScrollProgress, { passive: true }) + updateScrollProgress() + } + + return () => { + if (!supportsScrollTimeline) { + window.removeEventListener('scroll', updateScrollProgress, { passive: true }) + } + } + }, []) + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/ScrollIndicator/ScrollIndicator.types.ts b/src/components/ScrollIndicator/ScrollIndicator.types.ts new file mode 100644 index 00000000..6f8eb6dc --- /dev/null +++ b/src/components/ScrollIndicator/ScrollIndicator.types.ts @@ -0,0 +1,6 @@ +export interface ScrollIndicatorProps { + className?: string + color?: string + height?: string + position?: 'top' | 'bottom' +} \ No newline at end of file diff --git a/src/components/ScrollIndicator/index.ts b/src/components/ScrollIndicator/index.ts new file mode 100644 index 00000000..f290b5e4 --- /dev/null +++ b/src/components/ScrollIndicator/index.ts @@ -0,0 +1,2 @@ +export { ScrollIndicator } from './ScrollIndicator' +export type { ScrollIndicatorProps } from './ScrollIndicator.types' \ No newline at end of file diff --git a/src/components/Section/Section.styled.ts b/src/components/Section/Section.styled.ts new file mode 100644 index 00000000..e213a326 --- /dev/null +++ b/src/components/Section/Section.styled.ts @@ -0,0 +1,164 @@ +import styled from 'styled-components' + +const variants = { + default: { + bg: 'var(--bg-color)', + titleColor: 'var(--title-color)', + textColor: 'var(--text-color)', + }, + hero: { + bg: 'var(--section-hero-bg-color)', + titleColor: 'var(--section-hero-title-color)', + textColor: 'var(--section-hero-text-color)', + }, + tech: { + bg: 'var(--section-tech-bg-color)', + titleColor: 'var(--section-tech-title-color)', + textColor: 'var(--section-tech-text-color)', + }, + projects: { + bg: 'var(--section-projects-bg-color)', + titleColor: 'var(--section-projects-title-color)', + textColor: 'var(--section-projects-text-color)', + }, + articles: { + bg: 'var(--section-articles-bg-color)', + titleColor: 'var(--section-articles-title-color)', + textColor: 'var(--section-articles-text-color)', + }, + skills: { + bg: 'var(--section-skills-bg-color)', + titleColor: 'var(--section-skills-title-color)', + textColor: 'var(--section-skills-text-color)', + }, + footer: { + bg: 'var(--section-footer-bg-color)', + titleColor: 'var(--section-footer-title-color)', + textColor: 'var(--section-footer-text-color)', + }, +} + +type StyledProps = { + $variant?: string + $layout?: string + $flexDirection?: string + $alignItems?: string + $justifyContent?: string + $gap?: string +} + +export const StyledSection = styled.section` + width: 100%; + padding: var(--spacing-xl) var(--spacing-xl); + opacity: 0; + + /* Hero section should be visible immediately, then fade in with delay */ + &.section--hero { + opacity: 1; + + &.animate__animated { + animation-delay: var(--animate-delay, 0.3s); + } + } + + /* Apply custom animation delay if set */ + &[style*="--animate-delay"] { + &.animate__animated { + animation-delay: var(--animate-delay); + } + } + + &.animate__animated { + opacity: 1; + } + + /* Respect reduced motion preferences */ + @media (prefers-reduced-motion: reduce) { + opacity: 1 !important; + + &.animate__animated { + animation: none !important; + } + } + + &.section--cv { + padding: 0; + } + + ${props => props.theme.media.desktop} { + padding: var(--spacing-xl) var(--spacing-xl); + } + + ${props => props.theme.media.desktop} { + padding: var(--spacing-huge) 20px; + } + + background: ${props => + variants[props.$variant as keyof typeof variants]?.bg || variants.default.bg}; + position: relative; + + ${props => + props.$layout === 'full' && + ` + min-height: 100vh; + display: flex; + `} + + ${props => props.$justifyContent && `justify-content: ${props.$justifyContent};`} + + ${props => + props.$variant && + variants[props.$variant as keyof typeof variants] && + ` + h1, h2, h3, h4, h5, h6 { + color: ${variants[props.$variant as keyof typeof variants].titleColor}; + } + p { + color: ${variants[props.$variant as keyof typeof variants].textColor}; + } + `} + + ${props => + props.$variant === 'articles' && + ` + &::before { + content: ''; + position: absolute; + top: -5px; + left: 0; + right: 0; + height: 11px; + background-image: url("data:image/svg+xml,%3Csvg width='175' height='11' viewBox='0 0 175 11' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M137.453 2.49477C144.875 -0.738314 151.752 -1.00455 157.474 2.73403C160.915 4.98284 167.89 5.24901 174.213 2.49477V7.99575C166.791 11.2288 159.914 11.4949 154.192 7.75649C150.751 5.50764 146.173 5.24137 139.85 7.99575C132.428 11.2288 125.551 11.4949 119.829 7.75649C116.387 5.50764 111.809 5.24137 105.486 7.99575C98.0643 11.2288 91.1875 11.4949 85.4658 7.75649C82.0241 5.50764 77.4462 5.24137 71.123 7.99575C63.701 11.2288 56.8243 11.4949 51.1025 7.75649C47.6608 5.50764 43.0829 5.24137 36.7598 7.99575C29.3377 11.2288 22.461 11.4949 16.7393 7.75649C13.2975 5.50764 6.32316 5.24137 0 7.99575V2.49477C7.42215 -0.738323 14.2987 -1.00459 20.0205 2.73403C23.4622 4.98285 28.0402 5.24904 34.3633 2.49477C41.7854 -0.738321 48.662 -1.00458 54.3838 2.73403C57.8255 4.98285 62.4035 5.24903 68.7266 2.49477C76.1487 -0.738319 83.0253 -1.00457 88.7471 2.73403C92.1888 4.98285 96.7668 5.24903 103.09 2.49477C110.512 -0.738316 117.389 -1.00456 123.11 2.73403C126.552 4.98284 131.13 5.24902 137.453 2.49477Z' fill='%2357B99B'/%3E%3C/svg%3E"); + background-repeat: repeat-x; + background-size: 175px 11px; + background-position: 0 top; + pointer-events: none; + } + + [data-theme="dark"] & { + &::before { + display: none; + } + } + `} +` + +export const Container = styled.div` + max-width: 1200px; + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + + ${props => props.theme.media.desktop} { + gap: var(--spacing-xxl); + flex-direction: ${props => props.$flexDirection || 'column'}; + } + + ${props => props.$alignItems && `align-items: ${props.$alignItems};`} + + ${props => props.$justifyContent && `justify-content: ${props.$justifyContent};`} + + ${props => props.$layout === 'full' && `min-height: 100%;`} +` diff --git a/src/components/Section/Section.tsx b/src/components/Section/Section.tsx new file mode 100644 index 00000000..12f069d6 --- /dev/null +++ b/src/components/Section/Section.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef } from 'react' +import { Title, Text } from '@/components/Typography' +import { StyledSection, Container } from './Section.styled' +import { SectionProps } from './Section.types' + +export const Section = ({ + children, + variant, + layout = 'default', + flexDirection = 'column', + alignItems, + justifyContent, + gap = 'md', + title, + text, + id, + className = '', + hideTitle = false, + animate = true, + animationType = 'fadeIn', + animationDelay = 0, +}: SectionProps) => { + const sectionRef = useRef(null) + + useEffect(() => { + if (!animate || !sectionRef.current) { + if (sectionRef.current) { + sectionRef.current.style.opacity = '1' + } + return + } + + if (variant === 'hero' && sectionRef.current) { + const timer = setTimeout(() => { + if (sectionRef.current) { + sectionRef.current.classList.add('animate__animated', 'animate__fadeIn') + sectionRef.current.style.setProperty('--animate-delay', `${animationDelay || 0.3}s`) + } + }, 100) + + return () => clearTimeout(timer) + } + + // Check if section is already visible on mount (common on mobile) + const checkInitialVisibility = () => { + if (!sectionRef.current) return false + const rect = sectionRef.current.getBoundingClientRect() + const viewportHeight = window.innerHeight + // Check if section is in viewport + return rect.top < viewportHeight && rect.bottom > 0 + } + + const triggerAnimation = (element: HTMLElement) => { + element.classList.add('animate__animated', `animate__${animationType}`) + if (animationDelay > 0) { + element.style.setProperty('--animate-delay', `${animationDelay}s`) + } + } + + // If already visible, animate immediately (mobile fallback) + if (sectionRef.current && checkInitialVisibility()) { + const timer = setTimeout(() => { + if (sectionRef.current) { + triggerAnimation(sectionRef.current) + } + }, 150) + return () => clearTimeout(timer) + } + + // Set up Intersection Observer for scroll-triggered animations + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && entry.target instanceof HTMLElement) { + triggerAnimation(entry.target) + observer.unobserve(entry.target) + } + }) + }, + { + // More lenient for mobile - trigger when any part is visible + threshold: 0, + // Smaller rootMargin for mobile to trigger earlier + rootMargin: '0px 0px -30px 0px', + } + ) + + if (sectionRef.current) { + observer.observe(sectionRef.current) + } + + return () => { + if (sectionRef.current) { + observer.unobserve(sectionRef.current) + } + } + }, [animate, animationType, variant, animationDelay]) + + const bemClass = `section ${variant ? `section--${variant}` : ''} ${layout ? `section--${layout}` : ''}`.trim() + const fullClassName = `${bemClass} ${className}`.trim() + const titleId = title ? `${id}-title` : undefined; + const titleClassName = [ + "section__title", + variant ? `section__title--${variant}` : "", + hideTitle ? "sr-only" : "" + ].join(" ").trim(); + + return ( + + + {title && {title}} + {text && {text}} + {children} + + + ) +} diff --git a/src/components/Section/Section.types.ts b/src/components/Section/Section.types.ts new file mode 100644 index 00000000..feafe283 --- /dev/null +++ b/src/components/Section/Section.types.ts @@ -0,0 +1,30 @@ +import { ReactNode } from 'react' + +export type SectionVariant = 'hero' | 'tech' | 'projects' | 'articles' | 'skills' | 'footer' + +export type SectionLayout = 'default' | 'container' | 'centered' | 'full' + +export type AlignItems = 'flex-start' | 'center' | 'flex-end' | 'stretch' +export type JustifyContent = 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' +export type FlexDirection = 'row' | 'column' | 'row-reverse' | 'column-reverse' +export type Gap = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl' + +export type SectionProps = { + children: ReactNode + variant?: SectionVariant + layout?: SectionLayout + flexDirection?: FlexDirection + alignItems?: AlignItems + justifyContent?: JustifyContent + gap?: Gap + title?: string + text?: string + id?: string + className?: string + role?: 'main' | 'section' | 'aside' | 'article'; + ariaLabelledBy?: string; + hideTitle?: boolean; + animate?: boolean; + animationType?: 'fadeInUp' | 'fadeIn' | 'fadeInLeft' | 'fadeInRight' | 'zoomIn'; + animationDelay?: number; +} diff --git a/src/components/Section/index.ts b/src/components/Section/index.ts new file mode 100644 index 00000000..ec0a85e4 --- /dev/null +++ b/src/components/Section/index.ts @@ -0,0 +1,2 @@ +export { Section } from './Section' +export type { SectionProps, SectionVariant, SectionLayout } from './Section.types' diff --git a/src/components/Skills/Skills.styled.ts b/src/components/Skills/Skills.styled.ts new file mode 100644 index 00000000..a95550c4 --- /dev/null +++ b/src/components/Skills/Skills.styled.ts @@ -0,0 +1,42 @@ +import styled from 'styled-components' + +export const SkillsGrid = styled.div` + display: grid; + gap: var(--spacing-xxl); + width: 100%; + max-width: 1200px; + margin: 0 auto; + + ${props => props.theme.media.tablet} { + grid-template-columns: repeat(2, 1fr); + } + + ${props => props.theme.media.desktop} { + grid-template-columns: repeat(4, 1fr); + } +` + +export const SkillCategory = styled.div` + text-align: center; +` + +export const CategoryHeader = styled.div` + margin-bottom: var(--spacing-lg); + border-radius: var(--radius-sm); +` + +export const SkillsList = styled.ul` + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +` + +export const SkillItem = styled.li` + font-family: var(--text-font-family); + font-size: var(--text-md); + color: var(--section-skills-text-color); + text-align: left; +` \ No newline at end of file diff --git a/src/components/Skills/Skills.tsx b/src/components/Skills/Skills.tsx new file mode 100644 index 00000000..70aa62c8 --- /dev/null +++ b/src/components/Skills/Skills.tsx @@ -0,0 +1,64 @@ +import { Title, Text } from '@/components/Typography' +import { Section } from '@/components/Section' +import { SkillsProps } from './Skills.types' +import { + SkillsGrid, + SkillCategory, + CategoryHeader, + SkillsList, + SkillItem +} from './Skills.styled' + +export const Skills = ({ data }: SkillsProps) => { + if (!data) return null + + const skills = data?.skills + if (!skills || !Array.isArray(skills) || skills.length === 0) return null + + const getCategoryClass = (category: string) => { + const categoryLower = category.toLowerCase() + if (categoryLower.includes('frontend') || categoryLower.includes('code')) return 'code' + if (categoryLower.includes('backend') || categoryLower.includes('tool')) return 'toolbox' + if (categoryLower.includes('upcoming')) return 'upcoming' + return 'more' + } + + return ( +
    + + {skills.map((skill, index) => ( + + + + {skill.category} + + + {skill.items && skill.items.length > 0 && ( + + {skill.items.map((item, itemIndex) => ( + + + {item} + + + ))} + + )} + + ))} + +
    + ) +} \ No newline at end of file diff --git a/src/components/Skills/Skills.types.ts b/src/components/Skills/Skills.types.ts new file mode 100644 index 00000000..f08d1213 --- /dev/null +++ b/src/components/Skills/Skills.types.ts @@ -0,0 +1,12 @@ +export interface Skill { + category: string + items: string[] +} + +export interface SkillsData { + skills: Skill[] +} + +export interface SkillsProps { + data: SkillsData +} \ No newline at end of file diff --git a/src/components/Skills/index.ts b/src/components/Skills/index.ts new file mode 100644 index 00000000..f7f0b019 --- /dev/null +++ b/src/components/Skills/index.ts @@ -0,0 +1,2 @@ +export { Skills } from './Skills' +export type { Skill, SkillsData, SkillsProps } from './Skills.types' \ No newline at end of file diff --git a/src/components/SkipLink/SkipLink.styled.ts b/src/components/SkipLink/SkipLink.styled.ts new file mode 100644 index 00000000..e2af0036 --- /dev/null +++ b/src/components/SkipLink/SkipLink.styled.ts @@ -0,0 +1,48 @@ +import styled from 'styled-components'; + +export const StyledSkipLink = styled.a` + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border-width: 0; + background-color: var(--title-color); + color: var(--bg-color); + font-family: var(--text-font-family); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + text-decoration: none; + z-index: 9999; + transition: all 0.2s ease-in-out; + + &:focus, + &:focus-within, + &:active { + position: fixed; + top: 10px; + left: 10px; + width: auto; + height: auto; + padding: 12px 24px; + margin: 0; + overflow: visible; + clip: auto; + clip-path: none; + white-space: nowrap; + z-index: 999999; + background: var(--title-color); + color: var(--bg-color); + text-decoration: none; + border-radius: 4px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + outline: 3px solid var(--color-primary); + outline-offset: 2px; + } +`; \ No newline at end of file diff --git a/src/components/SkipLink/SkipLink.tsx b/src/components/SkipLink/SkipLink.tsx new file mode 100644 index 00000000..29f70e87 --- /dev/null +++ b/src/components/SkipLink/SkipLink.tsx @@ -0,0 +1,23 @@ +import { SkipLinkProps } from './SkipLink.types'; +import { StyledSkipLink } from './SkipLink.styled'; + +export const SkipLink = ({ href, children, className }: SkipLinkProps) => { + const hasValidChildren = children && String(children).trim().length > 0; + const linkText = hasValidChildren ? children : 'Skip link'; + + const ariaLabel = hasValidChildren + ? String(children).trim() + : `Skip to ${href.replace('#', '')}`; + + return ( + + {linkText} + + ); +}; + +export default SkipLink; \ No newline at end of file diff --git a/src/components/SkipLink/SkipLink.types.ts b/src/components/SkipLink/SkipLink.types.ts new file mode 100644 index 00000000..a9e32587 --- /dev/null +++ b/src/components/SkipLink/SkipLink.types.ts @@ -0,0 +1,5 @@ +export interface SkipLinkProps { + href: string; + children: React.ReactNode; + className?: string; +} \ No newline at end of file diff --git a/src/components/SkipLink/index.ts b/src/components/SkipLink/index.ts new file mode 100644 index 00000000..1232ec74 --- /dev/null +++ b/src/components/SkipLink/index.ts @@ -0,0 +1,2 @@ +export { SkipLink } from './SkipLink'; +export type { SkipLinkProps } from './SkipLink.types'; \ No newline at end of file diff --git a/src/components/StyleGuide/StyleGuide.styled.ts b/src/components/StyleGuide/StyleGuide.styled.ts new file mode 100644 index 00000000..6c59c4f9 --- /dev/null +++ b/src/components/StyleGuide/StyleGuide.styled.ts @@ -0,0 +1,255 @@ +import styled from 'styled-components'; + +export const StyleGuideContainer = styled.div` + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing-xl); + + ${props => props.theme.media.tablet} { + padding: var(--spacing-xxl); + } +`; + +export const Navigation = styled.nav` + position: sticky; + top: var(--spacing-lg); + background: var(--card-bg); + border-radius: var(--radius-lg); + padding: var(--spacing-md); + margin: var(--spacing-xl) 0; + display: flex; + flex-wrap: wrap; + gap: var(--spacing-sm); + border: 1px solid var(--border-color); + z-index: 100; + + ${props => props.theme.media.mobile} { + justify-content: center; + } +`; + +export const NavItem = styled.a` + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + text-decoration: none; + color: var(--text-color); + font-size: var(--text-sm); + font-weight: var(--weight-medium); + transition: all 0.2s ease; + + &:hover { + background: var(--title-color); + color: var(--bg-color); + border-color: var(--title-color); + } + + &:focus-visible { + outline: 2px solid var(--title-color); + outline-offset: 2px; + } +`; + +export const ComponentSection = styled.section` + margin: var(--spacing-xxl) 0; + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-color); + + &:first-of-type { + border-top: none; + margin-top: 0; + } +`; + +export const ExampleGrid = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-xl); + margin: var(--spacing-xl) 0; + + ${props => props.theme.media.tablet} { + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + } +`; + +export const ExampleCard = styled.div` + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: var(--spacing-lg); + background: var(--card-bg); + box-shadow: 0 2px 4px var(--card-shadow); + + h3 { + margin-top: 0; + margin-bottom: var(--spacing-sm); + } + + p { + margin-bottom: var(--spacing-md); + color: var(--text-muted); + } +`; + +export const Preview = styled.div` + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: var(--spacing-lg); + margin: var(--spacing-md) 0; + + /* Ensure preview content displays properly */ + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-sm); + + /* Handle different component layouts */ + > * { + margin: 0; + } + + /* Stack items vertically for titles */ + &:has(h1, h2, h3, h4, h5, h6) { + flex-direction: column; + align-items: flex-start; + } +`; + +export const CodeBlock = styled.div` + position: relative; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + + pre { + margin: 0; + padding: var(--spacing-md); + background: none; + color: var(--text-color); + font-family: 'SF Mono', Monaco, Inconsolata, 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: var(--text-sm); + line-height: 1.5; + overflow-x: auto; + white-space: pre; + } +`; + +export const CopyButton = styled.button` + position: absolute; + top: var(--spacing-sm); + right: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--title-color); + color: var(--bg-color); + border: none; + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: var(--weight-medium); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + opacity: 0.9; + } + + &:active { + transform: scale(0.95); + } + + &.copied { + background: var(--color-tertiary); + } + + &:focus-visible { + outline: 2px solid var(--bg-color); + outline-offset: -2px; + } +`; + +export const PropsTable = styled.div` + margin: var(--spacing-xl) 0; + + table { + width: 100%; + border-collapse: collapse; + background: var(--card-bg); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: 0 1px 3px var(--card-shadow); + } + + th, td { + padding: var(--spacing-sm) var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--border-color); + } + + th { + background: var(--bg-color); + font-weight: var(--weight-semibold); + color: var(--text-muted); + font-size: var(--text-sm); + } + + td { + font-size: var(--text-sm); + vertical-align: top; + } + + td:first-child { + font-family: 'SF Mono', Monaco, monospace; + background: var(--bg-color); + font-weight: var(--weight-medium); + color: var(--color-secondary); + } + + td:nth-child(2) { + font-family: 'SF Mono', Monaco, monospace; + font-size: var(--text-xs); + color: var(--color-primary); + } + + td:nth-child(3) { + font-family: 'SF Mono', Monaco, monospace; + font-size: var(--text-xs); + color: var(--color-tertiary); + } + + tbody tr:last-child td { + border-bottom: none; + } + + tbody tr:hover { + background: var(--bg-color); + } +`; + +export const ColorPalette = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: var(--spacing-md); + margin: var(--spacing-lg) 0; +`; + +export const ColorSwatch = styled.div<{ $color: string }>` + background: ${props => props.$color}; + height: 80px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + display: flex; + align-items: flex-end; + position: relative; + + &::after { + content: '${props => props.$color}'; + background: var(--text-color); + color: var(--bg-color); + padding: var(--spacing-xs); + font-size: var(--text-xs); + font-family: 'SF Mono', Monaco, monospace; + width: 100%; + text-align: center; + } +`; \ No newline at end of file diff --git a/src/components/StyleGuide/StyleGuide.tsx b/src/components/StyleGuide/StyleGuide.tsx new file mode 100644 index 00000000..58ee3e5a --- /dev/null +++ b/src/components/StyleGuide/StyleGuide.tsx @@ -0,0 +1,12 @@ +import { Title } from '@/components/Typography'; + +const StyleGuide = () => { + return ( +
    + Design System Style Guide +

    Welcome to the component library!

    +
    + ); +}; + +export default StyleGuide; \ No newline at end of file diff --git a/src/components/StyleGuide/index.ts b/src/components/StyleGuide/index.ts new file mode 100644 index 00000000..98915b9d --- /dev/null +++ b/src/components/StyleGuide/index.ts @@ -0,0 +1 @@ +export { default as StyleGuide } from './StyleGuide'; \ No newline at end of file diff --git a/src/components/Tag/Tag.styled.ts b/src/components/Tag/Tag.styled.ts new file mode 100644 index 00000000..5b81af41 --- /dev/null +++ b/src/components/Tag/Tag.styled.ts @@ -0,0 +1,75 @@ +import styled from 'styled-components' + +export const StyledTag = styled.span<{ + $clickable?: boolean; + $selected?: boolean; + $disabled?: boolean; + $variant?: 'default' | 'chip'; +}>` + display: inline-flex; + align-items: center; + padding: ${props => + props.$variant === 'chip' ? '0.5rem 1rem' : '0 var(--spacing-xs) 0px var(--spacing-xs)' + }; + height: ${props => + props.$variant === 'chip' ? 'auto' : '24px' + }; + background-color: ${props => { + if (props.$variant === 'chip') { + return props.$selected ? 'var(--chip-bg-active)' : 'var(--chip-bg-default)' + } + return props.$selected ? 'var(--tag-bg-color)' : + props.$disabled ? 'var(--tag-bg-color)' : + 'var(--tag-bg-color)' + }}; + border: ${props => + props.$variant === 'chip' ? 'none' : + `0px solid ${props.$selected ? 'var(--title-color)' : 'var(--border-color)'}` + }; + border-radius: ${props => + props.$variant === 'chip' ? '20px' : '0px' + }; + line-height: 1; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; + opacity: ${props => props.$disabled ? 0.6 : 1}; + + .tag__text { + color: ${props => { + if (props.$variant === 'chip') { + return props.$selected ? 'var(--text-color)' : 'var(--text-color)' + } + return props.$selected ? 'var(--tag-text-color)' : + props.$disabled ? 'var(--tag-text-color)' : + '#fff' + }} !important; + font-weight: ${props => + props.$variant === 'chip' && props.$selected ? '600' : + props.$variant === 'chip' ? '400' : + 'medium' + }; + font-size: ${props => + props.$variant === 'chip' ? '0.9rem' : 'inherit' + }; + } + + ${props => (props.$clickable && !props.$disabled) && ` + &:hover { + background-color: ${ + props.$selected ? 'var(--chip-bg-hover)' : 'var(--chip-bg-hover)' + }; + } + `} + + ${props => props.$clickable && !props.$disabled && ` + cursor: pointer; + `} + + ${props => props.$disabled && ` + cursor: not-allowed; + `} + + &:focus-visible { + outline: 2px solid var(--title-color); + outline-offset: 2px; + } +` \ No newline at end of file diff --git a/src/components/Tag/Tag.tsx b/src/components/Tag/Tag.tsx new file mode 100644 index 00000000..80719858 --- /dev/null +++ b/src/components/Tag/Tag.tsx @@ -0,0 +1,46 @@ +import { TagProps } from './Tag.types'; +import { StyledTag } from './Tag.styled'; +import { Text } from '@/components/Typography'; + +export const Tag = ({ + children, + className, + onClick, + selected, + disabled, + role, + ariaDescribedBy, + ariaPressed, + ariaSelected, + variant = 'default', +}: TagProps) => { + return ( + { + if (!disabled && onClick && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onClick(); + } + }} + role={role || (onClick ? 'button' : 'listitem')} + tabIndex={disabled ? -1 : onClick ? 0 : undefined} + aria-label={typeof children === 'string' ? children : undefined} + aria-pressed={role === 'button' ? (ariaPressed !== undefined ? ariaPressed : selected) : undefined} + aria-selected={role === 'tab' ? (ariaSelected !== undefined ? ariaSelected : selected) : undefined} + aria-disabled={disabled} + aria-describedby={ariaDescribedBy} + > + + {children} + + + ); +}; + +export default Tag; \ No newline at end of file diff --git a/src/components/Tag/Tag.types.ts b/src/components/Tag/Tag.types.ts new file mode 100644 index 00000000..ad7173ef --- /dev/null +++ b/src/components/Tag/Tag.types.ts @@ -0,0 +1,13 @@ +export interface TagProps { + children: React.ReactNode; + className?: string; + onClick?: () => void; + onRemove?: () => void; + selected?: boolean; + disabled?: boolean; + role?: string; + ariaDescribedBy?: string; + ariaPressed?: boolean; + ariaSelected?: boolean; + variant?: 'default' | 'chip'; +} \ No newline at end of file diff --git a/src/components/Tag/index.ts b/src/components/Tag/index.ts new file mode 100644 index 00000000..db8705b5 --- /dev/null +++ b/src/components/Tag/index.ts @@ -0,0 +1,2 @@ +export { Tag } from './Tag' +export type { TagProps } from './Tag.types' \ No newline at end of file diff --git a/src/components/TechStack/TechStack.tsx b/src/components/TechStack/TechStack.tsx new file mode 100644 index 00000000..e5644a9a --- /dev/null +++ b/src/components/TechStack/TechStack.tsx @@ -0,0 +1,44 @@ +import { Section } from '@/components/Section' +import { Text, Title } from '@/components/Typography' +import { TechStackProps } from './TechStack.types' + +export const TechStack = ({ data }: TechStackProps) => { + if (!data) return null + + const { title, desc } = data + + const hasContent = title || desc + if (!hasContent) return null + + return ( +
    + {title && ( + + {title} + + )} + + {desc && ( + + {desc} + + )} +
    + ) +} \ No newline at end of file diff --git a/src/components/TechStack/TechStack.types.ts b/src/components/TechStack/TechStack.types.ts new file mode 100644 index 00000000..5637ab57 --- /dev/null +++ b/src/components/TechStack/TechStack.types.ts @@ -0,0 +1,9 @@ +export type TechStackData = { + title?: string + desc?: string +} + +export type TechStackProps = { + data: TechStackData +} + diff --git a/src/components/TechStack/index.ts b/src/components/TechStack/index.ts new file mode 100644 index 00000000..bb14358b --- /dev/null +++ b/src/components/TechStack/index.ts @@ -0,0 +1,3 @@ +export { TechStack } from './TechStack' +export type { TechStackProps, TechStackData } from './TechStack.types' + diff --git a/src/components/ThemeToggle/ThemeToggle.styled.ts b/src/components/ThemeToggle/ThemeToggle.styled.ts new file mode 100644 index 00000000..f3230cd4 --- /dev/null +++ b/src/components/ThemeToggle/ThemeToggle.styled.ts @@ -0,0 +1,42 @@ +import styled from 'styled-components' + +export const ThemeToggleWrapper = styled.div` + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + + .theme-toggle__button { + // box-shadow: 0 2px 8px var(--card-shadow); + transition: all 0.3s ease; + + &:hover { + transform: scale(1.1) rotate(15deg); + box-shadow: 0 4px 12px var(--card-shadow); + border-color: ${props => props.theme.colors.primary} !important; + + svg { + color: ${props => props.theme.colors.primary}; + } + } + + &:active { + transform: scale(0.95); + } + + /* Smooth transition for the SVG icon */ + svg { + transition: all 0.3s ease; + } + } + + @media (max-width: 768px) { + top: 15px; + right: 15px; + + .theme-toggle__button { + width: 44px !important; + height: 44px !important; + } + } +` \ No newline at end of file diff --git a/src/components/ThemeToggle/ThemeToggle.tsx b/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 00000000..8438b0bb --- /dev/null +++ b/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,21 @@ +import { useTheme } from '@/contexts/ThemeContext' +import { Button } from '@/components/Button' +import { ThemeToggleWrapper } from './ThemeToggle.styled' + +export const ThemeToggle = () => { + const { theme, toggleTheme } = useTheme() + + return ( + + + + +`} + > + + + + + + + Small Button + +`} + > + + + + + + Default State + +`} + > + + + + + + View on GitHub +`} + > + + + + + + + {/* ICONS */} + + Icons + Scalable vector icons for UI elements and visual communication. + + Available Icons + + {availableIcons.map(iconName => ( + + +
    + {iconName} +
    + ))} +
    + + + + +`} + > + + + + + + + +`} + > + + + + + + + +`} + > + + + + + +
    + + {/* TAGS */} + + Tags + Small labels for categorization and status indication. + + + React +TypeScript +Styled Components +JavaScript`} + > + React + TypeScript + Styled Components + JavaScript + + + alert('Filter applied!')}> + Clickable Filter + +Selected Tag + {}}>Frontend`} + > + alert('Filter applied!')}>Clickable Filter + Selected Tag + {}}>Frontend + + + Default State +Selected State +Disabled State`} + > + Default State + Selected State + Disabled State + + + + + {/* CARDS */} + + Cards + Containers for displaying related information in a structured layout. + + + `} + > +
    + +
    +
    + + `} + > +
    + +
    +
    + + + Card Title + This is a basic card container that can hold any content you need. + +`} + > + + Card Title + This is a basic card container that can hold any content you need. + + + +
    +
    + + {/* LAYOUT */} + + Layout Components + Structural components for organizing content and creating responsive layouts. + + + + + Grid Item 1 + + + Grid Item 2 + + + Grid Item 3 + +`} + > + + + Grid Item 1 + + + Grid Item 2 + + + Grid Item 3 + + + + + + Section content goes here... +`} + > +
    + Section content goes here... +
    +
    +
    +
    + + {/* NAVIGATION */} + + Navigation + Components for helping users navigate and understand page structure. + + + + Skip to main content + + + Skip to navigation +`} + > +
    + + Press Tab to see skip links (they appear when focused) + + Skip to main content + Skip to navigation +
    +
    +
    +
    + + {/* HERO */} + + Hero Section + Main landing area with eye-catching content and call-to-action. + + + `} + > +
    + +
    +
    +
    +
    + + {/* FOOTER */} + + Footer + Site footer with contact information and social links. + + + `} + > +
    +
    +
    +
    +
    +
    + + {/* INTERACTIVE COMPONENTS */} + + Interactive Components + Dynamic components that enhance user experience. + + + `} + > +
    + Scroll the page to see the indicator in action (typically fixed at top of viewport) + +
    +
    + + `} + > + + + + `} + > + + + + `} + > + + + + `} + > +
    + +
    +
    + + `} + > + + +
    +
    + + {/* IMAGES */} + + Images + Image components with accessibility and performance optimizations. + + + +`} + > + + + + + +`} + > + Project screenshot + + + + + {/* USAGE GUIDELINES */} + + Usage Guidelines + Best practices for using the design system components. + +
    + Accessibility First + + All components follow WCAG 2.1 AA guidelines and include proper ARIA attributes, + keyboard navigation support, and screen reader compatibility. + + + Responsive Design + + Components are built with mobile-first responsive design principles and + work seamlessly across all device sizes. + + + Consistent Styling + + Use design tokens (CSS custom properties) for consistent spacing, colors, + and typography throughout your application. + + + Performance + + Components are optimized for performance with lazy loading, efficient renders, + and minimal bundle size impact. + +
    +
    + + {/* FOOTER */} +
    + + Design System by Daniel Lauding β€’ Built with React, TypeScript & Styled Components + +
    + + ); +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..44a5c9f4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 8b0f57b9..c513b7b5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,62 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + } + }, + build: { + // Use esbuild for faster minification (better than terser for most cases) + minify: 'esbuild', + // Enable source maps for production debugging (can disable for smaller bundles) + sourcemap: false, + // Optimize chunk size + chunkSizeWarningLimit: 1000, + // Enable CSS code splitting + cssCodeSplit: true, + rollupOptions: { + output: { + // Better chunk splitting strategy + manualChunks: (id) => { + // Vendor chunks + if (id.includes('node_modules')) { + if (id.includes('react') || id.includes('react-dom')) { + return 'react-vendor' + } + if (id.includes('react-router')) { + return 'router-vendor' + } + if (id.includes('styled-components')) { + return 'styled-vendor' + } + // Other vendor libraries + return 'vendor' + } + }, + // Optimize chunk file names + chunkFileNames: 'assets/js/[name]-[hash].js', + entryFileNames: 'assets/js/[name]-[hash].js', + assetFileNames: 'assets/[ext]/[name]-[hash].[ext]', + }, + }, + // Enable tree shaking + treeshake: true, + // Target modern browsers for smaller bundles + target: 'es2015', + }, + optimizeDeps: { + include: ['react', 'react-dom', 'react-router-dom', 'styled-components'], + // Exclude large dependencies from pre-bundling if not needed + exclude: [], + }, + // Enable compression + esbuild: { + drop: ['console', 'debugger'], + legalComments: 'none', + }, })