Compare commits

..

10 Commits

Author SHA1 Message Date
Craig
c1872d10c8 Refactor ContactUs component: update layout and styles for improved responsiveness, enhance typography, and streamline structure for better readability. 2025-04-15 16:22:29 +01:00
Craig
8411e46811 Remove ContactUs component and associated styles: delete ContactUs.tsx and ContactUs.module.css files to streamline the codebase. 2025-04-15 16:13:22 +01:00
Craig
9fa0d047eb Update HeaderSimple and HeroTitle components: modify styles for header background and borders, streamline imports, and enhance color scheme for improved visual consistency. 2025-04-15 15:52:26 +01:00
Craig
1e47e4d892 Add development workflow guidelines: introduce new rules for task progression, testing, and code refactoring to enhance development practices. 2025-04-15 15:52:19 +01:00
Craig
884ca29046 Implement standardized color system: update theme with new color tokens, add color inventory and migration guide documentation, and create ColorPalette component for showcasing colors. 2025-04-15 15:52:13 +01:00
Craig
1450d36a63 Add Mantine documentation to llms.txt, covering installation, core concepts, components, and best practices for usage. 2025-04-15 15:13:11 +01:00
Craig
8ed8764b5d Enhance README with project overview; update HeaderSimple component to include CV and Blog links; adjust HeroTitle styles and remove unused buttons for cleaner layout. 2025-04-15 15:13:03 +01:00
Craig
ed105c3857 Add TypeScript interface for testimonials and set default testimonials in the TestimonialsCarousel component. 2025-04-10 15:22:35 +01:00
Craig
0dc593c817 Refactor imports and improve layout in various components; update styles for consistency and readability. 2025-04-10 15:04:30 +01:00
Craig
55f56123ad move ot mantine frontend 2025-03-26 14:08:03 +00:00
89 changed files with 14500 additions and 6028 deletions

View File

@@ -0,0 +1,11 @@
---
description:
globs:
alwaysApply: true
---
Rule Name: development_workflow.mdc
Description:
- Do not move on to the next step of a task until user has confirmed the previous stage is finished. Pause occasionally and ask the user to accept changes.
- Run tests after changing code functionality.
- Use TDD principles where applicable.
- Before implementing code, consider if it is worth refactoring any code in the working area to be more clean, modular, and readable.

View File

@@ -0,0 +1,27 @@
name: npm test
on:
pull_request:
branches:
- '**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
jobs:
test_pull_request:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: '**/yarn.lock'
- name: Install dependencies
run: yarn
- name: Run build
run: npm run build
- name: Run tests
run: npm test

132
frontend/vite-template-master/.gitignore vendored Normal file
View File

@@ -0,0 +1,132 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store

View File

@@ -0,0 +1 @@
v22.11.0

View File

@@ -0,0 +1,35 @@
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
const config = {
printWidth: 100,
singleQuote: true,
trailingComma: 'es5',
plugins: ['@ianvs/prettier-plugin-sort-imports'],
importOrder: [
'.*styles.css$',
'',
'dayjs',
'^react$',
'^next$',
'^next/.*$',
'<BUILTIN_MODULES>',
'<THIRD_PARTY_MODULES>',
'^@mantine/(.*)$',
'^@mantinex/(.*)$',
'^@mantine-tests/(.*)$',
'^@docs/(.*)$',
'^@/.*$',
'^../(?!.*.css$).*$',
'^./(?!.*.css$).*$',
'\\.css$',
],
overrides: [
{
files: '*.mdx',
options: {
printWidth: 70,
},
},
],
};
export default config;

View File

@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
core: {
disableWhatsNewNotifications: true,
disableTelemetry: true,
enableCrashReports: false,
},
stories: ['../src/**/*.mdx', '../src/**/*.story.@(js|jsx|ts|tsx)'],
addons: ['storybook-dark-mode'],
framework: {
name: '@storybook/react-vite',
options: {},
},
};
export default config;

View File

@@ -0,0 +1,33 @@
import '@mantine/core/styles.css';
import React, { useEffect } from 'react';
import { addons } from '@storybook/preview-api';
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
import { MantineProvider, useMantineColorScheme } from '@mantine/core';
import { theme } from '../src/theme';
const channel = addons.getChannel();
export const parameters = {
layout: 'fullscreen',
options: {
showPanel: false,
},
};
function ColorSchemeWrapper({ children }: { children: React.ReactNode }) {
const { setColorScheme } = useMantineColorScheme();
const handleColorScheme = (value: boolean) => setColorScheme(value ? 'dark' : 'light');
useEffect(() => {
channel.on(DARK_MODE_EVENT_NAME, handleColorScheme);
return () => channel.off(DARK_MODE_EVENT_NAME, handleColorScheme);
}, [channel]);
return children;
}
export const decorators = [
(renderStory: any) => <ColorSchemeWrapper>{renderStory()}</ColorSchemeWrapper>,
(renderStory: any) => <MantineProvider theme={theme}>{renderStory()}</MantineProvider>,
];

View File

@@ -0,0 +1 @@
dist

View File

@@ -0,0 +1,43 @@
{
"extends": ["stylelint-config-standard-scss"],
"rules": {
"custom-property-pattern": null,
"selector-class-pattern": null,
"scss/no-duplicate-mixins": null,
"declaration-empty-line-before": null,
"declaration-block-no-redundant-longhand-properties": null,
"alpha-value-notation": null,
"custom-property-empty-line-before": null,
"property-no-vendor-prefix": null,
"color-function-notation": null,
"length-zero-no-unit": null,
"selector-not-notation": null,
"no-descending-specificity": null,
"comment-empty-line-before": null,
"scss/at-mixin-pattern": null,
"scss/at-rule-no-unknown": null,
"value-keyword-case": null,
"media-feature-range-notation": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
],
"color-named": "never",
"color-no-hex": true,
"function-disallowed-list": [
"rgb",
"rgba",
"hsl",
"hsla"
],
"declaration-property-value-disallowed-list": {
"color": ["/^#/", "/^rgb/", "/^hsl/"],
"background-color": ["/^#/", "/^rgb/", "/^hsl/"],
"border-color": ["/^#/", "/^rgb/", "/^hsl/"],
"fill": ["/^#/", "/^rgb/", "/^hsl/"],
"stroke": ["/^#/", "/^rgb/", "/^hsl/"]
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.7.0.cjs

View File

@@ -0,0 +1,36 @@
# Mantine Vite template
The project is a well-structured, modern React application with a clean design and strong technical foundation. It effectively showcases Craig Macfadyen's professional expertise but would benefit from completing the missing features and addressing the navigation structure. The use of Mantine provides a solid UI foundation, and the component architecture supports maintainability and future expansion.
## Features
This template comes with the following features:
- [PostCSS](https://postcss.org/) with [mantine-postcss-preset](https://mantine.dev/styles/postcss-preset)
- [TypeScript](https://www.typescriptlang.org/)
- [Storybook](https://storybook.js.org/)
- [Vitest](https://vitest.dev/) setup with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro)
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
## npm scripts
## Build and dev scripts
- `dev` start development server
- `build` build production version of the app
- `preview` locally preview production build
### Testing scripts
- `typecheck` checks TypeScript types
- `lint` runs ESLint
- `prettier:check` checks files with Prettier
- `vitest` runs vitest tests
- `vitest:watch` starts vitest watch
- `test` runs `vitest`, `prettier:check`, `lint` and `typecheck` scripts
### Other scripts
- `storybook` starts storybook dev server
- `storybook:build` build production storybook bundle to `storybook-static`
- `prettier:write` formats all files with Prettier

View File

@@ -0,0 +1,78 @@
# Current Color Usage Inventory
## Mantine Color Variables Used
### Basic Colors
- White: `var(--mantine-color-white)`
- Black: `var(--mantine-color-black)`
- Body: `var(--mantine-color-body)`
### Blue Shades
- Blue-0: `var(--mantine-color-blue-0)`
- Blue-1: `var(--mantine-color-blue-1)`
- Blue-4: `var(--mantine-color-blue-4)`
- Blue-6: `var(--mantine-color-blue-6)`
- Blue-7: `var(--mantine-color-blue-7)`
- Blue-filled: `var(--mantine-color-blue-filled)`
### Gray Shades
- Gray-0: `var(--mantine-color-gray-0)`
- Gray-3: `var(--mantine-color-gray-3)`
- Gray-4: `var(--mantine-color-gray-4)`
- Gray-5: `var(--mantine-color-gray-5)`
- Gray-7: `var(--mantine-color-gray-7)`
### Dark Shades
- Dark-0: `var(--mantine-color-dark-0)`
- Dark-4: `var(--mantine-color-dark-4)`
- Dark-6: `var(--mantine-color-dark-6)`
- Dark-8: `var(--mantine-color-dark-8)`
## Color Usage By Component
### HeroTitle
- Background: `light-dark(var(--mantine-color-white), var(--mantine-color-dark-8))`
- Text: `light-dark(var(--mantine-color-black), var(--mantine-color-white))`
### Welcome
- Text: `light-dark(var(--mantine-color-black), var(--mantine-color-white))`
### HeaderSimple
- Background: `var(--mantine-color-body)`
- Border: `light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))`
- Text: `light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0))`
- Menu Background: `light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))`
- Active Item Background: `var(--mantine-color-blue-filled)`
- Active Item Text: `var(--mantine-color-white)`
### ContactIcons
- Icon Color: `var(--mantine-color-white)`
- Icon Hover Color: `var(--mantine-color-blue-0)`
- Text Color: `var(--mantine-color-white)`
### Contact
- Background: `light-dark(var(--mantine-color-white), var(--mantine-color-dark-8))`
### ContactUs
- Gradient: `linear-gradient(to bottom right, var(--mantine-color-blue-4) 0%, var(--mantine-color-blue-7) 100%)`
- Title Color: `var(--mantine-color-white)`
- Subtitle Color: `var(--mantine-color-blue-0)`
- Form Background: `var(--mantine-color-white)`
- Form Button Text: `var(--mantine-color-white)`
- Form Button Hover: `var(--mantine-color-blue-1)`
- Input Background: `var(--mantine-color-white)`
- Input Border: `var(--mantine-color-gray-4)`
- Input Text: `var(--mantine-color-black)`
- Input Placeholder: `var(--mantine-color-gray-5)`
- Submit Button Background: `var(--mantine-color-blue-6)`
## Issues Identified
- Inconsistent use of blue shades across components
- Mix of direct color variables and light-dark functions
- No centralized theme definition with brand colors
- No semantic color naming (e.g., primary, secondary, etc.)
## Next Steps
- Define a comprehensive color palette in theme.ts
- Create semantic color tokens
- Standardize light/dark mode transitions

View File

@@ -0,0 +1,182 @@
# Color System Migration Guide
This guide will help you migrate your components to use our new standardized color system.
## Why Migrate?
- **Consistency**: Ensure all UI elements have consistent colors
- **Maintainability**: Easier to update the design system
- **Accessibility**: Better contrast and readability
- **Dark Mode**: Simplified light/dark mode support
## Migration Steps
### 1. Identify Current Color Usage
First, identify all hardcoded colors or direct Mantine color variables in your component:
```css
/* Before */
.element {
color: #333;
background-color: var(--mantine-color-blue-6);
border: 1px solid var(--mantine-color-gray-3);
}
```
### 2. Replace with Theme Color Tokens
Replace hardcoded colors and direct Mantine variables with our standardized tokens:
```css
/* After */
.element {
color: var(--mantine-color-brand-gray-7);
background-color: var(--mantine-color-brand-primary-6);
border: 1px solid var(--mantine-color-brand-gray-3);
}
```
### 3. Use Semantic Color Tokens Where Available
For common UI elements, use semantic tokens:
```css
/* Before */
.header {
background-color: var(--mantine-color-body);
border-bottom: 1px solid var(--mantine-color-gray-3);
}
/* After */
.header {
background-color: var(--mantine-other-headerBgColor);
border-bottom: 1px solid var(--mantine-other-headerBorderColor);
}
```
### 4. Use Light/Dark Mode Consistently
For elements that need different colors in light/dark mode:
```css
/* Before */
.text {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}
/* After */
.text {
color: light-dark(var(--mantine-color-brand-gray-7), var(--mantine-color-brand-gray-0));
}
```
### 5. Update Inline Styles in Components
For inline styles in components:
```tsx
// Before
<Box style={{ color: theme.colors.blue[6] }} />
// After
<Box style={{ color: 'var(--mantine-color-brand-primary-6)' }} />
// Or better, use Mantine props
<Box c="brand-primary.6" />
```
## Mapping Old Colors to New Colors
Use this reference to map old color values to our new color system:
| Old Color | New Color |
|-----------|-----------|
| `var(--mantine-color-blue-0)` | `var(--mantine-color-brand-primary-0)` |
| `var(--mantine-color-blue-1)` | `var(--mantine-color-brand-primary-1)` |
| `var(--mantine-color-blue-4)` | `var(--mantine-color-brand-primary-4)` |
| `var(--mantine-color-blue-6)` | `var(--mantine-color-brand-primary-6)` |
| `var(--mantine-color-blue-7)` | `var(--mantine-color-brand-primary-7)` |
| `var(--mantine-color-blue-filled)` | `var(--mantine-other-navigationActiveColor)` |
| `var(--mantine-color-gray-0)` | `var(--mantine-color-brand-gray-0)` |
| `var(--mantine-color-gray-3)` | `var(--mantine-color-brand-gray-3)` |
| `var(--mantine-color-gray-4)` | `var(--mantine-color-brand-gray-4)` |
| `var(--mantine-color-gray-5)` | `var(--mantine-color-brand-gray-5)` |
| `var(--mantine-color-gray-7)` | `var(--mantine-color-brand-gray-7)` |
| `var(--mantine-color-dark-0)` | `var(--mantine-color-brand-gray-0)` |
| `var(--mantine-color-dark-4)` | `var(--mantine-color-brand-gray-4)` |
| `var(--mantine-color-dark-6)` | `var(--mantine-color-brand-gray-6)` |
| `var(--mantine-color-dark-8)` | `var(--mantine-color-brand-gray-8)` |
## Examples
### Header Component Example
```css
/* Before */
.header {
background-color: var(--mantine-color-body);
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
}
.link {
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
}
.link:hover {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.active {
background-color: var(--mantine-color-blue-filled);
color: var(--mantine-color-white);
}
/* After */
.header {
background-color: var(--mantine-other-headerBgColor);
border-bottom: 1px solid var(--mantine-other-headerBorderColor);
}
.link {
color: light-dark(var(--mantine-color-brand-gray-7), var(--mantine-color-brand-gray-0));
}
.link:hover {
background-color: light-dark(var(--mantine-color-brand-gray-1), var(--mantine-color-brand-gray-8));
}
.active {
background-color: var(--mantine-other-navigationActiveColor);
color: var(--mantine-color-white);
}
```
### Button Component Example
```tsx
// Before
<Button
style={{
backgroundColor: theme.colors.blue[6],
color: 'white'
}}
>
Click me
</Button>
// After
<Button color="brand-primary">Click me</Button>
```
## Testing Your Migration
After migrating, run stylelint to ensure you're not using hardcoded colors:
```bash
yarn stylelint
```
## Need Help?
Refer to the `color-tokens.md` file or the `ColorPalette` component in Storybook for a complete reference of our color system.

View File

@@ -0,0 +1,182 @@
# Color Tokens Documentation
This document provides guidelines for using our standardized color system across the application.
## Color Palette Structure
Each color in our system has 10 shades, numbered from 0 to 9, where:
- 0 is the lightest shade
- 9 is the darkest shade
- 5-6 are the base/primary shades
## Brand Colors
### Brand Primary (Blue)
This is our main brand color, used for primary actions, links, and brand identity elements.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-brand-primary-0)` | `#EAF4FF` | Background on hover states |
| `var(--mantine-color-brand-primary-1)` | `#D5E9FF` | Background of informational elements |
| `var(--mantine-color-brand-primary-2)` | `#B0D3FF` | Secondary backgrounds |
| `var(--mantine-color-brand-primary-3)` | `#8BBDFF` | Borders, separators |
| `var(--mantine-color-brand-primary-4)` | `#66A7FF` | Hover states for interactive elements |
| `var(--mantine-color-brand-primary-5)` | `#4191FF` | Icons, secondary buttons |
| `var(--mantine-color-brand-primary-6)` | `#1C7BFF` | **Primary buttons, links, focus states** |
| `var(--mantine-color-brand-primary-7)` | `#0065F2` | Active/pressed states |
| `var(--mantine-color-brand-primary-8)` | `#0055CC` | Tertiary text |
| `var(--mantine-color-brand-primary-9)` | `#004099` | Text on light backgrounds |
### Brand Secondary
This complementary color is used for secondary UI elements and visual hierarchy.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-brand-secondary-0)` | `#F0F4F9` | Alternative background |
| `var(--mantine-color-brand-secondary-1)` | `#E1E9F4` | Secondary hover backgrounds |
| `var(--mantine-color-brand-secondary-2)` | `#C3D4E9` | Disabled backgrounds |
| `var(--mantine-color-brand-secondary-3)` | `#A5BFDE` | Borders, dividers |
| `var(--mantine-color-brand-secondary-4)` | `#87AAD3` | Non-primary icons |
| `var(--mantine-color-brand-secondary-5)` | `#6995C8` | Highlighted text |
| `var(--mantine-color-brand-secondary-6)` | `#4B80BD` | **Secondary buttons, links** |
| `var(--mantine-color-brand-secondary-7)` | `#2D6BB2` | Active/pressed states for secondary elements |
| `var(--mantine-color-brand-secondary-8)` | `#1F5A99` | Secondary text on light backgrounds |
| `var(--mantine-color-brand-secondary-9)` | `#114480` | Dark text on light backgrounds |
### Brand Accent
This color is used for accent elements, call-to-actions, and highlighting important UI elements.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-brand-accent-0)` | `#FFF0EA` | Highlight backgrounds |
| `var(--mantine-color-brand-accent-1)` | `#FFE1D5` | Notification backgrounds |
| `var(--mantine-color-brand-accent-2)` | `#FFC3AD` | Secondary accent backgrounds |
| `var(--mantine-color-brand-accent-3)` | `#FFA584` | Accent borders |
| `var(--mantine-color-brand-accent-4)` | `#FF875B` | Accent hover states |
| `var(--mantine-color-brand-accent-5)` | `#FF6932` | Secondary CTAs |
| `var(--mantine-color-brand-accent-6)` | `#FF4A09` | **Primary CTAs, important actions** |
| `var(--mantine-color-brand-accent-7)` | `#D93A00` | Active/pressed states for accent elements |
| `var(--mantine-color-brand-accent-8)` | `#B02F00` | Dark accent text on light backgrounds |
| `var(--mantine-color-brand-accent-9)` | `#882400` | Very dark accent, use sparingly |
### Brand Gray
This is our neutral color, used for text, backgrounds, borders, and non-emphasized UI elements.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-brand-gray-0)` | `#F8F9FA` | Page backgrounds, light mode |
| `var(--mantine-color-brand-gray-1)` | `#F1F3F5` | Card/element backgrounds |
| `var(--mantine-color-brand-gray-2)` | `#E9ECEF` | Alternative backgrounds, hover states |
| `var(--mantine-color-brand-gray-3)` | `#DEE2E6` | **Borders, dividers, separators** |
| `var(--mantine-color-brand-gray-4)` | `#CED4DA` | Disabled elements, secondary borders |
| `var(--mantine-color-brand-gray-5)` | `#ADB5BD` | **Placeholder text, disabled text** |
| `var(--mantine-color-brand-gray-6)` | `#868E96` | **Secondary text** |
| `var(--mantine-color-brand-gray-7)` | `#495057` | **Primary text** |
| `var(--mantine-color-brand-gray-8)` | `#343A40` | Headings, emphasized text |
| `var(--mantine-color-brand-gray-9)` | `#212529` | Extra dark text, header text |
## Semantic Colors
### Success
Used for positive actions, success messages, and confirmations.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-success-0)` | `#EAFAF1` | Success background |
| `var(--mantine-color-success-1)` | `#D5F5E3` | Light success background |
| `var(--mantine-color-success-2)` | `#ABEBC6` | Secondary success background |
| `var(--mantine-color-success-3)` | `#82E0AA` | Success borders |
| `var(--mantine-color-success-4)` | `#58D68D` | Success hover states |
| `var(--mantine-color-success-5)` | `#2ECC71` | Secondary success elements |
| `var(--mantine-color-success-6)` | `#27AE60` | **Primary success elements, icons** |
| `var(--mantine-color-success-7)` | `#229954` | Active/pressed states |
| `var(--mantine-color-success-8)` | `#1E8449` | Dark success text |
| `var(--mantine-color-success-9)` | `#196F3D` | Very dark success, use sparingly |
### Warning
Used for warnings, alerts that need attention but aren't critical.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-warning-0)` | `#FEF9E7` | Warning background |
| `var(--mantine-color-warning-1)` | `#FCF3CF` | Light warning background |
| `var(--mantine-color-warning-2)` | `#F9E79F` | Secondary warning background |
| `var(--mantine-color-warning-3)` | `#F7DC6F` | Warning borders |
| `var(--mantine-color-warning-4)` | `#F4D03F` | Warning hover states |
| `var(--mantine-color-warning-5)` | `#F1C40F` | Secondary warning elements |
| `var(--mantine-color-warning-6)` | `#D4AC0D` | **Primary warning elements, icons** |
| `var(--mantine-color-warning-7)` | `#B7950B` | Active/pressed states |
| `var(--mantine-color-warning-8)` | `#9A7D0A` | Dark warning text |
| `var(--mantine-color-warning-9)` | `#7D6608` | Very dark warning, use sparingly |
### Error
Used for errors, destructive actions, and critical alerts.
| Token | Hex | Usage |
|-------|-----|-------|
| `var(--mantine-color-error-0)` | `#FDEDEC` | Error background |
| `var(--mantine-color-error-1)` | `#FADBD8` | Light error background |
| `var(--mantine-color-error-2)` | `#F5B7B1` | Secondary error background |
| `var(--mantine-color-error-3)` | `#F1948A` | Error borders |
| `var(--mantine-color-error-4)` | `#EC7063` | Error hover states |
| `var(--mantine-color-error-5)` | `#E74C3C` | Secondary error elements |
| `var(--mantine-color-error-6)` | `#CB4335` | **Primary error elements, icons** |
| `var(--mantine-color-error-7)` | `#B03A2E` | Active/pressed states |
| `var(--mantine-color-error-8)` | `#943126` | Dark error text |
| `var(--mantine-color-error-9)` | `#78281F` | Very dark error, use sparingly |
## Semantic Tokens
For common UI elements, use these semantic tokens:
| Token | Maps to | Usage |
|-------|--------|-------|
| `var(--mantine-other-headerBgColor)` | `var(--mantine-color-body)` | Header background |
| `var(--mantine-other-headerBorderColor)` | `var(--mantine-color-brand-gray-3)` | Header borders |
| `var(--mantine-other-navigationActiveColor)` | `var(--mantine-color-brand-primary-6)` | Active navigation items |
| `var(--mantine-other-navigationHoverColor)` | `var(--mantine-color-brand-primary-0)` | Hover state for navigation |
| `var(--mantine-other-buttonPrimaryBg)` | `var(--mantine-color-brand-primary-6)` | Primary button background |
| `var(--mantine-other-buttonSecondaryBg)` | `var(--mantine-color-brand-secondary-6)` | Secondary button background |
| `var(--mantine-other-buttonAccentBg)` | `var(--mantine-color-brand-accent-6)` | Accent button background |
## Usage in CSS
### For direct CSS usage
```css
.my-element {
color: var(--mantine-color-brand-primary-6);
background-color: var(--mantine-color-brand-gray-0);
border: 1px solid var(--mantine-color-brand-gray-3);
}
```
### For light/dark mode support
```css
.my-element {
color: light-dark(var(--mantine-color-brand-gray-7), var(--mantine-color-brand-gray-0));
background-color: light-dark(var(--mantine-color-brand-gray-0), var(--mantine-color-brand-gray-8));
}
```
## Usage in Components (with Mantine)
```tsx
import { Button } from '@mantine/core';
// Using theme colors
<Button color="brand-primary">Primary Button</Button>
<Button color="brand-secondary">Secondary Button</Button>
<Button color="brand-accent">Accent Button</Button>
// Using specific shade
<Button color="brand-primary.6">Custom Shade Button</Button>
```

View File

@@ -0,0 +1,11 @@
import mantine from 'eslint-config-mantine';
import tseslint from 'typescript-eslint';
export default tseslint.config(
...mantine,
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', './.storybook/main.ts'] },
{
files: ['**/*.story.tsx'],
rules: { 'no-console': 'off' },
}
);

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
/>
<title>Vite + Mantine App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
{
"name": "mantine-vite-template",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "npm run eslint && npm run stylelint",
"eslint": "eslint . --cache",
"stylelint": "stylelint '**/*.css' --cache",
"prettier": "prettier --check \"**/*.{ts,tsx}\"",
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
"vitest": "vitest run",
"vitest:watch": "vitest",
"test": "npm run typecheck && npm run prettier && npm run lint && npm run vitest && npm run build",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
},
"dependencies": {
"@mantine/carousel": "^7.17.2",
"@mantine/core": "7.17.2",
"@mantine/hooks": "7.17.2",
"@mantinex/mantine-logo": "^1.1.0",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^7.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.4.0"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
"@ianvs/prettier-plugin-sort-imports": "^4.4.1",
"@storybook/react": "^8.6.8",
"@storybook/react-vite": "^8.6.8",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.11",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.23.0",
"eslint-config-mantine": "^4.0.3",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.4",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^26.0.0",
"postcss": "^8.5.3",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.3",
"prop-types": "^15.8.1",
"storybook": "^8.6.8",
"storybook-dark-mode": "^4.0.2",
"stylelint": "^16.16.0",
"stylelint-config-standard-scss": "^14.0.0",
"typescript": "^5.8.2",
"typescript-eslint": "^8.27.0",
"vite": "^6.2.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9"
},
"packageManager": "yarn@4.7.0"
}

View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

View File

@@ -0,0 +1,13 @@
import '@mantine/core/styles.css';
import { MantineProvider } from '@mantine/core';
import { Router } from './Router';
import { theme } from './theme';
export default function App() {
return (
<MantineProvider theme={theme}>
<Router />
</MantineProvider>
);
}

View File

@@ -0,0 +1,13 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { HomePage } from './pages/Home.page';
const router = createBrowserRouter([
{
path: '/',
element: <HomePage />,
},
]);
export function Router() {
return <RouterProvider router={router} />;
}

View File

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 372 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/react';
import { ColorPalette } from './ColorPalette';
const meta = {
title: 'Design System/ColorPalette',
component: ColorPalette,
tags: ['autodocs'],
parameters: {
layout: 'padded',
},
} satisfies Meta<typeof ColorPalette>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -0,0 +1,154 @@
import { Box, Group, Stack, Text, Title, useMantineTheme } from '@mantine/core';
interface ColorSwatchProps {
colorName: string;
shade: number;
hex: string;
}
function ColorSwatch({ colorName, shade, hex }: ColorSwatchProps) {
return (
<Box>
<Box
style={{
backgroundColor: hex,
width: '100%',
height: 50,
borderRadius: 5,
marginBottom: 5,
border: '1px solid var(--mantine-color-brand-gray-3)',
}}
/>
<Text size="xs" fw={700}>
{colorName}-{shade}
</Text>
<Text size="xs" c="dimmed">
{hex}
</Text>
</Box>
);
}
interface ColorGroupProps {
title: string;
colorName: string;
description: string;
colors: readonly string[];
}
function ColorGroup({ title, colorName, description, colors }: ColorGroupProps) {
return (
<Stack gap="md">
<Box mb={10}>
<Title order={3}>{title}</Title>
<Text size="sm">{description}</Text>
</Box>
<Group grow>
{colors.map((color, index) => (
<ColorSwatch key={index} colorName={colorName} shade={index} hex={color} />
))}
</Group>
</Stack>
);
}
export function ColorPalette() {
const theme = useMantineTheme();
return (
<Stack gap="xl">
<Title order={1}>Color Palette Documentation</Title>
<Text>
This component showcases all colors defined in our theme system. Use these colors
consistently throughout the application for a cohesive design.
</Text>
<ColorGroup
title="Brand Primary"
colorName="brand-primary"
description="Main brand color for primary actions, links, and brand identity elements."
colors={theme.colors['brand-primary']}
/>
<ColorGroup
title="Brand Secondary"
colorName="brand-secondary"
description="Complementary color for secondary UI elements and visual hierarchy."
colors={theme.colors['brand-secondary']}
/>
<ColorGroup
title="Brand Accent"
colorName="brand-accent"
description="Accent color for call-to-actions and highlighting important elements."
colors={theme.colors['brand-accent']}
/>
<ColorGroup
title="Brand Gray"
colorName="brand-gray"
description="Neutral colors for text, backgrounds, borders, and non-emphasized UI elements."
colors={theme.colors['brand-gray']}
/>
<ColorGroup
title="Success"
colorName="success"
description="Used for positive actions, success messages, and confirmations."
colors={theme.colors.success}
/>
<ColorGroup
title="Warning"
colorName="warning"
description="Used for warnings and alerts that need attention but aren't critical."
colors={theme.colors.warning}
/>
<ColorGroup
title="Error"
colorName="error"
description="Used for errors, destructive actions, and critical alerts."
colors={theme.colors.error}
/>
<Box my={20}>
<Title order={2}>Semantic Color Usage</Title>
<Text mb={10}>These are recommended usages for our color system:</Text>
<Stack gap="xs">
<Text>
<b>Primary Buttons:</b> brand-primary-6
</Text>
<Text>
<b>Secondary Buttons:</b> brand-secondary-6
</Text>
<Text>
<b>Accent Buttons:</b> brand-accent-6
</Text>
<Text>
<b>Text on Light Backgrounds:</b> brand-gray-7
</Text>
<Text>
<b>Text on Dark Backgrounds:</b> brand-gray-0
</Text>
<Text>
<b>Borders & Dividers:</b> brand-gray-3
</Text>
<Text>
<b>Page Backgrounds:</b> brand-gray-0
</Text>
<Text>
<b>Success States:</b> success-6
</Text>
<Text>
<b>Warning States:</b> warning-6
</Text>
<Text>
<b>Error States:</b> error-6
</Text>
</Stack>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,24 @@
.icon {
width: 22px;
height: 22px;
}
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@@ -0,0 +1,21 @@
import cx from 'clsx';
import { TbMoon, TbSun } from 'react-icons/tb';
import { ActionIcon, useMantineColorScheme } from '@mantine/core';
import classes from './ColorSchemeToggle.module.css';
export function ColorSchemeToggle() {
// from https://mantine.dev/theming/color-schemes/
const { colorScheme, setColorScheme } = useMantineColorScheme();
return (
<ActionIcon
onClick={() => setColorScheme(colorScheme === 'light' ? 'dark' : 'light')}
variant="default"
size="xl"
aria-label="Toggle color scheme"
>
<TbSun className={cx(classes.icon, classes.light)} />
<TbMoon className={cx(classes.icon, classes.dark)} />
</ActionIcon>
);
}

View File

@@ -0,0 +1,63 @@
.wrapper {
position: relative;
box-sizing: border-box;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
}
.inner {
position: relative;
padding-top: 120px;
padding-bottom: 120px;
width: 80%;
}
/* Small screen styles */
@media (max-width: 755px) {
.inner {
padding-bottom: 80px;
padding-top: 80px;
}
}
.title {
font-family:
"Greycliff CF",
var(--mantine-font-family);
font-size: 62px;
font-weight: 900;
line-height: 1.1;
margin: 0;
padding: 0;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
}
/* Small screen styles */
@media (max-width: 755px) {
.title {
font-size: 42px;
line-height: 1.2;
}
}
.description {
margin-top: var(--mantine-spacing-xl);
font-size: 24px;
}
/* Small screen styles */
@media (max-width: 755px) {
.description {
font-size: 18px;
}
}
.link {
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.link:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,36 @@
import { Anchor, Container, Text, Stack } from '@mantine/core';
import classes from './Contact.module.css';
export function Contact() {
return (
<div className={classes.wrapper}>
<Container fluid size={700} className={classes.inner}>
<h1 className={classes.title}>
<Text component="span" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }} inherit>
Get in Touch
</Text>
</h1>
<Text className={classes.description} c="dimmed">
If you'd like to collaborate, have any questions, or just want to say hello, feel free to
reach out!
</Text>
<Stack gap="lg" mt="xl">
<Text size="lg">
Email:{' '}
<Anchor href="mailto:cdmacfadyen@proton.me" className={classes.link}>
cdmacfadyen@proton.me
</Anchor>
</Text>
<Text size="lg">
LinkedIn:{' '}
<Anchor href="https://www.linkedin.com/in/craig-macfadyen-9a2041197" target="_blank" className={classes.link}>
linkedin.com/in/craigmacfadyen
</Anchor>
</Text>
</Stack>
</Container>
</div>
);
}

View File

@@ -0,0 +1,18 @@
.wrapper {
display: flex;
align-items: center;
color: var(--mantine-color-white);
}
.icon {
margin-right: var(--mantine-spacing-md);
background-color: transparent;
}
.title {
color: var(--mantine-color-blue-0);
}
.description {
color: var(--mantine-color-white);
}

View File

@@ -0,0 +1,33 @@
import { TbAt, TbSun } from 'react-icons/tb';
import { Box, Stack, Text } from '@mantine/core';
import classes from './ContactIcons.module.css';
interface ContactIconProps extends Omit<React.ComponentPropsWithoutRef<'div'>, 'title'> {
icon: typeof TbSun;
title: React.ReactNode;
description: React.ReactNode;
}
function ContactIcon({ icon: Icon, title, description, ...others }: ContactIconProps) {
return (
<div className={classes.wrapper} {...others}>
<Box mr="md">
<Icon size={24} />
</Box>
<div>
<Text size="xs" className={classes.title}>
{title}
</Text>
<Text className={classes.description}>{description}</Text>
</div>
</div>
);
}
const MOCKDATA = [{ title: 'Email', description: 'hello@mantine.dev', icon: TbAt }];
export function ContactIconsList() {
const items = MOCKDATA.map((item, index) => <ContactIcon key={index} {...item} />);
return <Stack>{items}</Stack>;
}

View File

@@ -0,0 +1,3 @@
.section {
margin-top: 80px;
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Carousel } from '@mantine/carousel';
import { Container, Grid, Image, Stack, Text, Title } from '@mantine/core';
import classes from './ExpertiseSection.module.css';
interface ExpertiseSectionProps {
title: string;
content: string;
images: { url: string; alt: string }[];
viewMoreLink: string;
imageOnLeft?: boolean;
}
const ExpertiseSection: React.FC<ExpertiseSectionProps> = ({
title,
content,
images,
viewMoreLink: _viewMoreLink,
imageOnLeft = false,
}) => {
const slides = images.map((image) => (
<Carousel.Slide key={image.url}>
<Image
src={image.url}
alt={image.alt}
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
/>
</Carousel.Slide>
));
return (
<Container>
<Grid className={classes.section}>
<Grid.Col order={imageOnLeft ? 2 : 1} span={{ xs: 6, md: 8 }}>
<Stack>
<Title>{title}</Title>
<Text size="lg">{content}</Text>
{/* <Button component="a" href={viewMoreLink}>
View More
</Button> */}
</Stack>
</Grid.Col>
<Grid.Col order={imageOnLeft ? 1 : 2} span={{ xs: 6, md: 4 }}>
<Carousel
slideSize={300}
align="start"
slideGap="md"
controlsOffset="xl"
controlSize={28}
loop
withIndicators
>
{slides}
</Carousel>
</Grid.Col>
</Grid>
</Container>
);
};
export default ExpertiseSection;

View File

@@ -0,0 +1,32 @@
.header {
height: 56px;
background-color: var(--mantine-other-headerBgColor);
border-bottom: 1px solid var(--mantine-other-headerBorderColor);
}
.inner {
height: 56px;
display: flex;
justify-content: space-between;
align-items: center;
}
.link {
display: block;
line-height: 1;
padding: 8px 12px;
border-radius: var(--mantine-radius-sm);
text-decoration: none;
color: light-dark(var(--mantine-color-brand-gray-7), var(--mantine-color-brand-gray-0));
font-size: var(--mantine-font-size-sm);
font-weight: 500;
@mixin hover {
background-color: light-dark(var(--mantine-color-brand-gray-1), var(--mantine-color-brand-gray-8));
}
[data-mantine-color-scheme] &[data-active] {
background-color: var(--mantine-other-navigationActiveColor);
color: var(--mantine-color-white);
}
}

View File

@@ -0,0 +1,28 @@
import { Anchor, Burger, Container, Group } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { MantineLogo } from '@mantinex/mantine-logo';
import { ColorSchemeToggle } from '../ColorSchemeToggle/ColorSchemeToggle';
import classes from './HeaderSimple.module.css';
export function HeaderSimple() {
const [opened, { toggle }] = useDisclosure(false);
return (
<header className={classes.header}>
<Container size="md" className={classes.inner}>
<MantineLogo size={28} />
<Group gap={5} visibleFrom="xs">
<Anchor href="#" className={classes.link}>
CV
</Anchor>
<Anchor href="#" className={classes.link}>
Blog
</Anchor>
</Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="xs" size="sm" />
<ColorSchemeToggle />
</Container>
</header>
);
}

View File

@@ -0,0 +1,43 @@
.wrapper {
position: relative;
box-sizing: border-box;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-8));
}
.inner {
position: relative;
padding-top: 120px;
padding-bottom: 120px;
width: 80%;
@media (max-width: $mantine-breakpoint-sm) {
padding-bottom: 80px;
padding-top: 80px;
}
}
.title {
font-family:
"Greycliff CF",
var(--mantine-font-family);
font-size: 62px;
font-weight: 900;
line-height: 1.1;
margin: 0;
padding: 0;
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
@media (max-width: $mantine-breakpoint-sm) {
font-size: 42px;
line-height: 1.2;
}
}
.description {
margin-top: var(--mantine-spacing-xl);
font-size: 24px;
@media (max-width: $mantine-breakpoint-sm) {
font-size: 18px;
}
}

View File

@@ -0,0 +1,24 @@
import { Container, Text } from '@mantine/core';
// import { GithubIcon } from '@mantinex/dev-icons';
import classes from './HeroTitle.module.css';
export function HeroTitle() {
return (
<div className={classes.wrapper}>
<Container fluid size={700} className={classes.inner}>
<h1 className={classes.title}>
{' '}
<Text component="span" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }} inherit>
Craig Macfadyen
</Text>{' '}
</h1>
<Text className={classes.description} c="dimmed">
Data Scientist with experience in consulting and research. Expertise in Computer Vision
and Large Language Models. Take a look at what I can do for your business and get in touch
if you have any questions or you'd like to work together!
</Text>
</Container>
</div>
);
}

View File

@@ -0,0 +1,8 @@
.card {
padding-left: 4rem; /* Add more padding to the left */
padding-right: 4rem; /* Add more padding to the right */
}
.wrapper {
margin-bottom: 80px;
}

View File

@@ -0,0 +1,69 @@
import Autoplay from 'embla-carousel-autoplay';
import { Carousel } from '@mantine/carousel';
import { Avatar, Card, Group, Stack, Text, Title } from '@mantine/core';
import styles from './Testimonial.module.css';
export interface TestimonialItem {
text: string;
clientName: string;
role: string;
business: string;
}
const defaultTestimonials: TestimonialItem[] = [
{
text: "What set Craig apart was his ability to understand our business challenges and deliver a solution that worked for us. He didn't just build a model, he delivered a system our team could actually use.",
clientName: 'Steven Adair',
role: 'Director',
business: 'Managing Utilities Limited',
},
];
interface TestimonialsCarouselProps {
testimonials?: TestimonialItem[];
}
function TestimonialsCarousel({ testimonials = defaultTestimonials }: TestimonialsCarouselProps) {
const autoplay = Autoplay();
return (
<div className={styles.wrapper}>
<Title ta="center" mb="xl">
Testimonials
</Title>
<Carousel
slideSize="100%"
slideGap="md"
loop
align="start"
plugins={[autoplay]}
onMouseEnter={autoplay.stop}
onMouseLeave={autoplay.reset}
withControls
>
{testimonials.map((testimonial, index) => (
<Carousel.Slide key={index}>
<Card shadow="sm" padding="lg" radius="md" withBorder className={styles.card}>
<Stack gap="sm">
<Text size="lg">{testimonial.text}</Text>
<Group gap="sm">
<Avatar radius="xl">{testimonial.clientName.charAt(0)}</Avatar>
<div>
<Text size="lg" fw={700}>
{testimonial.clientName}
</Text>
<Text size="sm" c="dimmed">
{testimonial.role}, {testimonial.business}
</Text>
</div>
</Group>
</Stack>
</Card>
</Carousel.Slide>
))}
</Carousel>
</div>
);
}
export default TestimonialsCarousel;

View File

@@ -0,0 +1,10 @@
.title {
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
font-size: rem(100px);
font-weight: 900;
letter-spacing: rem(-2px);
@media (max-width: $mantine-breakpoint-md) {
font-size: rem(50px);
}
}

View File

@@ -0,0 +1,7 @@
import { Welcome } from './Welcome';
export default {
title: 'Welcome',
};
export const Usage = () => <Welcome />;

View File

@@ -0,0 +1,12 @@
import { render, screen } from '@test-utils';
import { Welcome } from './Welcome';
describe('Welcome component', () => {
it('has correct Vite guide link', () => {
render(<Welcome />);
expect(screen.getByText('this guide')).toHaveAttribute(
'href',
'https://mantine.dev/guides/vite/'
);
});
});

View File

@@ -0,0 +1,23 @@
import { Anchor, Text, Title } from '@mantine/core';
import classes from './Welcome.module.css';
export function Welcome() {
return (
<>
<Title className={classes.title} ta="center" mt={100}>
Welcome to{' '}
<Text inherit variant="gradient" component="span" gradient={{ from: 'pink', to: 'yellow' }}>
Mantine
</Text>
</Title>
<Text c="dimmed" ta="center" size="lg" maw={580} mx="auto" mt="xl">
This starter Vite project includes a minimal setup, if you want to learn more on Mantine +
Vite integration follow{' '}
<Anchor href="https://mantine.dev/guides/vite/" size="lg">
this guide
</Anchor>
. To get started edit pages/Home.page.tsx file.
</Text>
</>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 163 163"><path fill="#339AF0" d="M162.162 81.5c0-45.011-36.301-81.5-81.08-81.5C36.301 0 0 36.489 0 81.5 0 126.51 36.301 163 81.081 163s81.081-36.49 81.081-81.5z"/><path fill="#fff" d="M65.983 43.049a6.234 6.234 0 00-.336 6.884 6.14 6.14 0 001.618 1.786c9.444 7.036 14.866 17.794 14.866 29.52 0 11.726-5.422 22.484-14.866 29.52a6.145 6.145 0 00-1.616 1.786 6.21 6.21 0 00-.694 4.693 6.21 6.21 0 001.028 2.186 6.151 6.151 0 006.457 2.319 6.154 6.154 0 002.177-1.035 50.083 50.083 0 007.947-7.39h17.493c3.406 0 6.174-2.772 6.174-6.194s-2.762-6.194-6.174-6.194h-9.655a49.165 49.165 0 004.071-19.69 49.167 49.167 0 00-4.07-19.692h9.66c3.406 0 6.173-2.771 6.173-6.194 0-3.422-2.762-6.193-6.173-6.193H82.574a50.112 50.112 0 00-7.952-7.397 6.15 6.15 0 00-4.578-1.153 6.189 6.189 0 00-4.055 2.438h-.006z"/><path fill="#fff" fill-rule="evenodd" d="M56.236 79.391a9.342 9.342 0 01.632-3.608 9.262 9.262 0 011.967-3.077 9.143 9.143 0 012.994-2.063 9.06 9.06 0 017.103 0 9.145 9.145 0 012.995 2.063 9.262 9.262 0 011.967 3.077 9.339 9.339 0 01-2.125 10.003 9.094 9.094 0 01-6.388 2.63 9.094 9.094 0 01-6.39-2.63 9.3 9.3 0 01-2.755-6.395z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,6 @@
import ReactDOM from 'react-dom/client';
import App from './App';
import '@mantine/carousel/styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);

View File

@@ -0,0 +1,35 @@
import { Container } from '@mantine/core';
import openWebUIGif from '@/assets/open-webui.gif';
import outputGif from '@/assets/output.gif';
import { Contact } from '@/components/ContactUs/Contact';
import ExpertiseSection from '@/components/ExpertiseSection/ExpertiseSection';
import { HeaderSimple } from '@/components/HeaderSimple/HeaderSimple';
import { HeroTitle } from '@/components/HeroTitle/HeroTitle';
import Testimonial from '@/components/Testimonial/Testimonial';
export function HomePage() {
return (
<>
<HeaderSimple />
<HeroTitle />
<ExpertiseSection
title="Computer Vision"
content="Expertise in developing and deploying computer vision models for various applications, including object detection, image classification, and more. Ask me about state-of-the-art real-time models for your business."
images={[{ url: outputGif, alt: 'Video Segmentation with SAM2.' }]}
viewMoreLink="/computer-vision"
imageOnLeft
/>
<ExpertiseSection
title="Natural Language Processing"
content="Building NLP models for tasks such as sentiment analysis, text classification, and language generation. If you need a chatbot for your business and care about privacy, or a simple text classification algorithm to understand your data, I can help."
images={[{ url: openWebUIGif, alt: 'Locally hosted LLM using oLLama and OpenWebUI.' }]}
viewMoreLink="/nlp"
imageOnLeft={false}
/>
<Container mt={20}>
<Testimonial />
</Container>
<Contact />
</>
);
}

View File

@@ -0,0 +1,140 @@
import { createTheme, rem } from '@mantine/core';
export const theme = createTheme({
/** Color system for the application */
colors: {
// Primary brand color (blue shades from inventory with consistent gradation)
'brand-primary': [
'#EAF4FF', // 0: Lightest shade
'#D5E9FF', // 1
'#B0D3FF', // 2
'#8BBDFF', // 3
'#66A7FF', // 4: Base blue shade
'#4191FF', // 5
'#1C7BFF', // 6
'#0065F2', // 7: Original blue-7 shade
'#0055CC', // 8
'#004099', // 9: Darkest shade
],
// Secondary brand color - a complementary shade to the primary
'brand-secondary': [
'#F0F4F9', // 0
'#E1E9F4', // 1
'#C3D4E9', // 2
'#A5BFDE', // 3
'#87AAD3', // 4
'#6995C8', // 5
'#4B80BD', // 6
'#2D6BB2', // 7
'#1F5A99', // 8
'#114480', // 9
],
// Accent color for highlights, call to actions, important elements
'brand-accent': [
'#FFF0EA', // 0
'#FFE1D5', // 1
'#FFC3AD', // 2
'#FFA584', // 3
'#FF875B', // 4
'#FF6932', // 5
'#FF4A09', // 6
'#D93A00', // 7
'#B02F00', // 8
'#882400', // 9
],
// Standardized gray shades for consistency
'brand-gray': [
'#F8F9FA', // 0
'#F1F3F5', // 1
'#E9ECEF', // 2
'#DEE2E6', // 3
'#CED4DA', // 4
'#ADB5BD', // 5
'#868E96', // 6
'#495057', // 7
'#343A40', // 8
'#212529', // 9
],
// Semantic colors for state indications
success: [
'#EAFAF1', // 0
'#D5F5E3', // 1
'#ABEBC6', // 2
'#82E0AA', // 3
'#58D68D', // 4
'#2ECC71', // 5
'#27AE60', // 6
'#229954', // 7
'#1E8449', // 8
'#196F3D', // 9
],
warning: [
'#FEF9E7', // 0
'#FCF3CF', // 1
'#F9E79F', // 2
'#F7DC6F', // 3
'#F4D03F', // 4
'#F1C40F', // 5
'#D4AC0D', // 6
'#B7950B', // 7
'#9A7D0A', // 8
'#7D6608', // 9
],
error: [
'#FDEDEC', // 0
'#FADBD8', // 1
'#F5B7B1', // 2
'#F1948A', // 3
'#EC7063', // 4
'#E74C3C', // 5
'#CB4335', // 6
'#B03A2E', // 7
'#943126', // 8
'#78281F', // 9
],
},
// Set the primary color to our brand-primary
primaryColor: 'brand-primary',
// Add consistent spacing values
spacing: {
xs: rem(4),
sm: rem(8),
md: rem(16),
lg: rem(24),
xl: rem(32),
'2xl': rem(40),
'3xl': rem(64),
},
// Add consistent border radius values
radius: {
xs: rem(2),
sm: rem(4),
md: rem(8),
lg: rem(16),
xl: rem(24),
},
// Other theme customizations
fontFamily:
'"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
// Add semantic tokens for common elements
other: {
headerBgColor: 'var(--mantine-color-body)',
headerBorderColor: 'var(--mantine-color-brand-gray-3)',
navigationActiveColor: 'var(--mantine-color-brand-primary-6)',
navigationHoverColor: 'var(--mantine-color-brand-primary-0)',
buttonPrimaryBg: 'var(--mantine-color-brand-primary-6)',
buttonSecondaryBg: 'var(--mantine-color-brand-secondary-6)',
buttonAccentBg: 'var(--mantine-color-brand-accent-6)',
},
});

View File

@@ -0,0 +1,5 @@
import userEvent from '@testing-library/user-event';
export * from '@testing-library/react';
export { render } from './render';
export { userEvent };

View File

@@ -0,0 +1,11 @@
import { render as testingLibraryRender } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import { theme } from '../src/theme';
export function render(ui: React.ReactNode) {
return testingLibraryRender(ui, {
wrapper: ({ children }: { children: React.ReactNode }) => (
<MantineProvider theme={theme}>{children}</MantineProvider>
),
});
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"types": ["node", "@testing-library/jest-dom", "vitest/globals"],
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"],
"@test-utils": ["./test-utils"]
}
},
"include": ["src", "test-utils"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './vitest.setup.mjs',
},
});

View File

@@ -0,0 +1,28 @@
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';
const { getComputedStyle } = window;
window.getComputedStyle = (elt) => getComputedStyle(elt);
window.HTMLElement.prototype.scrollIntoView = () => {};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,71 +0,0 @@
# Setup process that worked:
```bash
npm create vite@latest
915 cd website/
916 npm install
917 npm run dev
918 npm install tailwindcss @tailwindcss/vite
919 npm run dev
920 npm i @types/node
921 npm run dev
922 npx shadcn@canary init
923 npm run dev
924 npm shadcn@canary add button
925 npx shadcn@canary add button
926 npm run dev
```
# Setup
TODO
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,28 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Craig Macfadyen</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,44 +0,0 @@
{
"name": "website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-slot": "^1.1.2",
"@tailwindcss/vite": "^4.0.8",
"@types/node": "^22.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.5.2",
"lucide-react": "^0.476.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.8",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,51 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
.App {
font-family: sans-serif;
}
section {
padding: 20px;
margin-bottom: 20px;
}

View File

@@ -1,22 +0,0 @@
import React from 'react';
import './App.css';
import Hero from './components/Hero';
import About from './components/About';
import Projects from './components/Projects';
import Contact from './components/Contact';
import Navbar04Page from './components/navbar-04/navbar-04';
import Testimonial06 from './components/testimonial-06/testimonial-06';
function App() {
return (
<div className="App min-h-screen bg-muted rounded-3xl">
<title>Home | Craig Macfadyen</title>
<Navbar04Page />
<Hero />
<About />
<Testimonial06 />
<Contact />
</div>
);
}
export default App;

View File

@@ -1,36 +0,0 @@
import React from 'react';
import ExpertiseSection from './ExpertiseSection';
import outputGif from '@/assets/output.gif';
import openWebUIGif from '@/assets/open-webui.gif';
const About = () => {
return (
<section id="about" className="about py-20 bg-gray-100">
<div className="container mx-auto">
<p className="text-lg mb-8">
You have data, I have the skills to help you understand it.
</p>
<ExpertiseSection
title="Computer Vision"
content="Expertise in developing and deploying computer vision models for various applications, including object detection, image classification, and more. Ask me about state-of-the-art real-time models for your business."
images={[
{ url: outputGif, alt: "Video Segmentation with SAM2." },
]}
viewMoreLink="/computer-vision"
imageOnLeft={true}
/>
<ExpertiseSection
title="Natural Language Processing"
content="Building NLP models for tasks such as sentiment analysis, text classification, and language generation. If you need a chatbot for your business and care about privacy, or a simple text classification algorithm to understand your data, I can help."
images={[
{ url: openWebUIGif, alt: "Locally hosted LLM using oLLama and OpenWebUI." },
]}
viewMoreLink="/nlp"
imageOnLeft={false}
/>
</div>
</section>
);
};
export default About;

View File

@@ -1,28 +0,0 @@
import { Button } from "@/components/ui/button";
import { SiLinkedin } from "react-icons/si";
const Contact = () => {
return (
<section id="contact" className="contact py-20">
<div className="container mx-auto">
<h2 className="text-3xl font-bold mb-4">Contact Me</h2>
<p className="text-lg mb-4">
Get in touch with me at <a href="mailto:cdmacfadyen@proton.me" className="text-blue-500">cdmacfadyen@proton.me</a>, or drop me a message on LinkedIn.
</p>
<div className="flex items-center justify-center gap-3">
<Button asChild>
<a href="mailto:cdmacfadyen@proton.me">Email Me</a>
</Button>
<Button asChild>
<a href="https://www.linkedin.com/in/craig-macfadyen-9a2041197/" target="_blank" rel="noopener noreferrer">
<SiLinkedin className="w-6 h-6 mr-2" />
LinkedIn
</a>
</Button>
</div>
</div>
</section>
);
};
export default Contact;

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { Button } from "@/components/ui/button";
import ImageCarousel from './ImageCarousel';
interface ExpertiseSectionProps {
title: string;
content: string;
images: { url: string; alt: string }[];
viewMoreLink: string;
imageOnLeft?: boolean;
}
const ExpertiseSection: React.FC<ExpertiseSectionProps> = ({
title,
content,
images,
viewMoreLink,
imageOnLeft = false,
}) => {
return (
<section className="expertise-section py-20 bg-gray-100">
<div className={`container mx-auto flex flex-col md:flex-row items-center ${imageOnLeft ? 'md:flex-row-reverse' : ''}`}>
<div className="md:w-1/3 mb-8 mx-10 md:mb-0">
<ImageCarousel images={images} />
</div>
<div className="md:w-2/3 md:px-8">
<h2 className="text-3xl font-bold mb-4">{title}</h2>
<p className="text-lg mb-4">{content}</p>
{/* <Button asChild>
<a href={viewMoreLink}>View More</a>
</Button> */}
</div>
</div>
</section>
);
};
export default ExpertiseSection;

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { Button } from "@/components/ui/button"
const Hero = () => {
return (
<section id="hero" className="hero">
<div className="container mx-auto text-center">
<h1 className="text-5xl font-bold mb-4">Craig Macfadyen</h1>
<p className="text-xl mb-8">
Data Scientist with experience in consulting and research. Expertise in Computer Vision and Large Language Models. Take a look at what
I can do for your business and get in touch if you have any questions or you'd like to work together!
</p>
</div>
</section>
);
};
export default Hero;

View File

@@ -1,43 +0,0 @@
import React from 'react';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import Autoplay from "embla-carousel-autoplay";
interface ImageCarouselProps {
images: { url: string; alt: string }[];
delay?: number;
}
const ImageCarousel: React.FC<ImageCarouselProps> = ({ images, delay = 8000 }) => {
return (
<div className="relative">
<Carousel
plugins={[
Autoplay({
delay,
}),
]}
>
<CarouselContent>
{images.map((image, index) => (
<CarouselItem key={index}>
<img src={image.url} alt={image.alt} className="rounded-lg shadow-md" />
<div className="text-center py-2 rounded-lg bg-white">
{image.alt}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
};
export default ImageCarousel;

View File

@@ -1,28 +0,0 @@
import React from 'react';
const Projects = () => {
return (
<section id="projects" className="projects py-20">
<div className="container mx-auto">
<h2 className="text-3xl font-bold mb-4">Projects</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{/* Example Project Card */}
<div className="bg-white rounded-lg shadow-md p-4">
<h3 className="text-xl font-semibold mb-2">Project 1</h3>
<p className="text-gray-700">Description of Project 1.</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<h3 className="text-xl font-semibold mb-2">Project 2</h3>
<p className="text-gray-700">Description of Project 2.</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<h3 className="text-xl font-semibold mb-2">Project 3</h3>
<p className="text-gray-700">Description of Project 3.</p>
</div>
</div>
</div>
</section>
);
};
export default Projects;

View File

@@ -1,19 +0,0 @@
export const Logo = () => (
<svg
id="logo-7"
width="124"
height="32"
viewBox="0 0 124 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M36.87 10.07H39.87V22.2H36.87V10.07ZM41.06 17.62C41.06 14.62 42.9 12.83 45.74 12.83C48.58 12.83 50.42 14.62 50.42 17.62C50.42 20.62 48.62 22.42 45.74 22.42C42.86 22.42 41.06 20.67 41.06 17.62ZM47.41 17.62C47.41 15.97 46.76 15 45.74 15C44.72 15 44.08 16 44.08 17.62C44.08 19.24 44.71 20.22 45.74 20.22C46.77 20.22 47.41 19.3 47.41 17.63V17.62ZM51.55 22.79H54.43C54.5671 23.0945 54.7988 23.3466 55.0907 23.5088C55.3826 23.6709 55.7191 23.7345 56.05 23.69C57.19 23.69 57.79 23.07 57.79 22.17V20.49H57.73C57.491 21.0049 57.1031 21.4363 56.6165 21.7287C56.1299 22.021 55.5668 22.1608 55 22.13C52.81 22.13 51.36 20.46 51.36 17.59C51.36 14.72 52.74 12.91 55.04 12.91C55.6246 12.8871 56.2022 13.0434 56.6955 13.3579C57.1888 13.6725 57.5742 14.1303 57.8 14.67V14.67V13H60.8V22.1C60.8 24.29 58.87 25.65 56.02 25.65C53.37 25.65 51.72 24.46 51.55 22.8V22.79ZM57.8 17.61C57.8 16.15 57.13 15.23 56.07 15.23C55.01 15.23 54.36 16.14 54.36 17.61C54.36 19.08 55 19.91 56.07 19.91C57.14 19.91 57.8 19.1 57.8 17.62V17.61ZM61.93 17.61C61.93 14.61 63.77 12.82 66.61 12.82C69.45 12.82 71.3 14.61 71.3 17.61C71.3 20.61 69.5 22.41 66.61 22.41C63.72 22.41 61.93 20.67 61.93 17.62V17.61ZM68.28 17.61C68.28 15.96 67.63 14.99 66.61 14.99C65.59 14.99 65 16 65 17.63C65 19.26 65.63 20.23 66.65 20.23C67.67 20.23 68.28 19.3 68.28 17.63V17.61ZM72.44 10.82C72.4321 10.5171 72.5144 10.2187 72.6763 9.96261C72.8383 9.70651 73.0726 9.50427 73.3496 9.38151C73.6266 9.25875 73.9338 9.221 74.2323 9.27305C74.5308 9.32511 74.8071 9.46462 75.0262 9.67389C75.2454 9.88317 75.3974 10.1528 75.4631 10.4486C75.5288 10.7444 75.5052 11.053 75.3952 11.3354C75.2853 11.6177 75.094 11.8611 74.8456 12.0346C74.5973 12.2081 74.3029 12.304 74 12.31C73.7992 12.3238 73.5977 12.2959 73.4082 12.2281C73.2186 12.1603 73.0452 12.0541 72.8987 11.916C72.7522 11.778 72.6358 11.6111 72.5569 11.4259C72.4779 11.2408 72.4381 11.0413 72.44 10.84V10.82ZM72.44 13.02H75.44V22.2H72.44V13.02ZM86.33 17.61C86.33 20.61 85 22.32 82.72 22.32C82.1354 22.3575 81.5533 22.2146 81.0525 21.9106C80.5517 21.6065 80.1564 21.156 79.92 20.62H79.86V25.14H76.86V13H79.86V14.64H79.92C80.1454 14.0951 80.5332 13.6329 81.0306 13.3162C81.528 12.9995 82.1109 12.8437 82.7 12.87C85 12.91 86.37 14.63 86.37 17.63L86.33 17.61ZM83.33 17.61C83.33 16.15 82.66 15.22 81.61 15.22C80.56 15.22 79.89 16.16 79.88 17.61C79.87 19.06 80.56 19.99 81.61 19.99C82.66 19.99 83.33 19.08 83.33 17.63V17.61ZM91.48 12.81C93.97 12.81 95.48 13.99 95.55 15.88H92.82C92.82 15.23 92.28 14.82 91.45 14.82C90.62 14.82 90.25 15.14 90.25 15.61C90.25 16.08 90.58 16.23 91.25 16.37L93.17 16.76C95 17.15 95.78 17.89 95.78 19.28C95.78 21.18 94.05 22.4 91.5 22.4C88.95 22.4 87.28 21.18 87.15 19.31H90.04C90.13 19.99 90.67 20.39 91.55 20.39C92.43 20.39 92.83 20.1 92.83 19.62C92.83 19.14 92.55 19.04 91.83 18.89L90.1 18.52C88.31 18.15 87.37 17.2 87.37 15.8C87.39 14 89 12.83 91.48 12.83V12.81ZM105.79 22.18H102.9V20.47H102.84C102.681 21.0441 102.331 21.5466 101.847 21.8941C101.363 22.2415 100.775 22.413 100.18 22.38C99.7242 22.4059 99.2682 22.3337 98.8427 22.1682C98.4172 22.0027 98.0322 21.7479 97.7137 21.4208C97.3952 21.0938 97.1505 20.7021 96.9964 20.2724C96.8422 19.8427 96.7821 19.3849 96.82 18.93V13H99.82V18.24C99.82 19.33 100.38 19.91 101.31 19.91C101.528 19.9104 101.744 19.8643 101.943 19.7746C102.141 19.6849 102.319 19.5537 102.463 19.3899C102.606 19.226 102.714 19.0333 102.777 18.8247C102.84 18.616 102.859 18.3962 102.83 18.18V13H105.83L105.79 22.18ZM107.24 13H110.14V14.77H110.2C110.359 14.2035 110.702 13.7057 111.174 13.3547C111.646 13.0037 112.222 12.8191 112.81 12.83C113.409 12.7821 114.003 12.9612 114.476 13.3318C114.948 13.7024 115.264 14.2372 115.36 14.83H115.42C115.601 14.2309 115.977 13.7093 116.488 13.3472C116.998 12.9851 117.615 12.8031 118.24 12.83C118.648 12.8163 119.054 12.8886 119.432 13.0422C119.811 13.1957 120.152 13.4272 120.435 13.7214C120.718 14.0157 120.936 14.3662 121.075 14.7501C121.213 15.134 121.27 15.5429 121.24 15.95V22.2H118.24V16.75C118.24 15.75 117.79 15.29 116.95 15.29C116.763 15.2884 116.577 15.327 116.406 15.4032C116.235 15.4794 116.082 15.5914 115.958 15.7317C115.834 15.872 115.741 16.0372 115.686 16.2163C115.631 16.3955 115.616 16.5843 115.64 16.77V22.2H112.79V16.71C112.79 15.79 112.34 15.29 111.52 15.29C111.331 15.2901 111.143 15.3303 110.971 15.408C110.798 15.4858 110.643 15.5993 110.518 15.741C110.392 15.8827 110.298 16.0495 110.241 16.2304C110.185 16.4112 110.167 16.6019 110.19 16.79V22.2H107.19L107.24 13Z"
className="fill-foreground"
/>
<path
d="M28.48 10.62C27.9711 9.45636 27.2976 8.37193 26.48 7.4C25.2715 5.92034 23.7633 4.71339 22.0547 3.8586C20.3461 3.00382 18.4758 2.52057 16.567 2.44066C14.6582 2.36075 12.7541 2.68599 10.98 3.39499C9.20597 4.10398 7.60217 5.18065 6.2742 6.55413C4.94622 7.9276 3.92417 9.56675 3.27532 11.3637C2.62647 13.1606 2.36552 15.0746 2.50966 16.9796C2.65381 18.8847 3.19976 20.7376 4.1116 22.4164C5.02344 24.0953 6.28049 25.562 7.80001 26.72C8.77501 27.4779 9.85236 28.094 11 28.55C12.609 29.2094 14.3311 29.549 16.07 29.55C19.6594 29.5421 23.0992 28.1113 25.6355 25.5713C28.1717 23.0313 29.5974 19.5894 29.6 16C29.6026 14.1485 29.2213 12.3166 28.48 10.62V10.62ZM16.06 5.18999C17.6216 5.18983 19.1643 5.53113 20.58 6.18999V6.18999C20.2348 6.33916 19.8718 6.44335 19.5 6.5C18.2766 6.67709 17.1433 7.24507 16.2692 8.11917C15.3951 8.99326 14.8271 10.1266 14.65 11.35C14.5723 12.0361 14.2602 12.6744 13.7665 13.1572C13.2728 13.64 12.6277 13.9376 11.94 14C10.7166 14.1771 9.58327 14.7451 8.70918 15.6192C7.83509 16.4933 7.2671 17.6266 7.09001 18.85C7.03005 19.5024 6.7517 20.1155 6.30001 20.59V20.59C5.52066 18.9433 5.17056 17.1261 5.28228 15.3077C5.394 13.4893 5.96391 11.7287 6.93898 10.1897C7.91404 8.65079 9.26258 7.38351 10.8591 6.50584C12.4556 5.62817 14.2482 5.16864 16.07 5.16999L16.06 5.18999ZM7.79001 23C7.91001 22.89 8.03001 22.79 8.15001 22.67C9.03966 21.8075 9.61072 20.6689 9.77001 19.44C9.83459 18.7492 10.143 18.104 10.64 17.62C11.1183 17.1222 11.762 16.8163 12.45 16.76C13.6734 16.5829 14.8067 16.0149 15.6808 15.1408C16.5549 14.2667 17.1229 13.1334 17.3 11.91C17.3433 11.1875 17.6533 10.5068 18.17 10C18.6601 9.51185 19.3099 9.2171 20 9.16999C21.1239 9.01536 22.1721 8.51571 23 7.74C23.9427 8.52207 24.7413 9.46289 25.36 10.52C25.322 10.5713 25.2784 10.6183 25.23 10.66C24.7527 11.1622 24.1098 11.4748 23.42 11.54C22.1953 11.714 21.0603 12.281 20.1856 13.1556C19.311 14.0303 18.744 15.1653 18.57 16.39C18.4995 17.0784 18.1932 17.7213 17.703 18.2097C17.2127 18.6982 16.5687 19.0021 15.88 19.07C14.653 19.2457 13.5155 19.8126 12.6363 20.6863C11.7572 21.5601 11.1833 22.6941 11 23.92C10.9462 24.4087 10.7783 24.878 10.51 25.29C9.484 24.6808 8.5651 23.9072 7.79001 23V23ZM16.06 26.86C15.0453 26.8611 14.0354 26.7197 13.06 26.44C13.3937 25.818 13.6106 25.1401 13.7 24.44C13.7701 23.7531 14.075 23.1114 14.5632 22.6232C15.0514 22.135 15.6931 21.8301 16.38 21.76C17.6052 21.5849 18.7408 21.0178 19.6169 20.1435C20.4929 19.2693 21.0624 18.1348 21.24 16.91C21.3101 16.2231 21.615 15.5814 22.1032 15.0932C22.5914 14.605 23.2331 14.3001 23.92 14.23C24.842 14.1101 25.7208 13.7668 26.48 13.23C26.9016 14.8279 26.9515 16.5011 26.626 18.1213C26.3005 19.7415 25.6081 21.2657 24.6021 22.5768C23.5961 23.8878 22.3032 24.9511 20.8224 25.6849C19.3417 26.4187 17.7126 26.8036 16.06 26.81V26.86Z"
className="fill-foreground"
/>
</svg>
);

View File

@@ -1,29 +0,0 @@
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from "@/components/ui/navigation-menu";
import { NavigationMenuProps } from "@radix-ui/react-navigation-menu";
export const NavMenu = (props: NavigationMenuProps) => (
<NavigationMenu {...props}>
<NavigationMenuList className="gap-6 space-x-0 data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-start">
<NavigationMenuItem>
<NavigationMenuLink asChild>
<a href="/">Home</a>
</NavigationMenuLink>
</NavigationMenuItem>
{/* <NavigationMenuItem>
<NavigationMenuLink asChild>
<a href="#">Blog</a>
</NavigationMenuLink>
</NavigationMenuItem> */}
<NavigationMenuItem>
<NavigationMenuLink asChild>
<a href="#contact">Contact</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
);

View File

@@ -1,42 +0,0 @@
import { Logo } from "./logo";
import { NavMenu } from "./nav-menu";
import { NavigationSheet } from "./navigation-sheet";
import { SiLinkedin, SiGithub, SiGitea } from "react-icons/si";
const Navbar04Page = () => {
return (
<nav className="top-6 inset-x-4 h-16 bg-background border dark:border-slate-700/70 max-w-screen-xl mx-auto rounded-full">
<div className="h-full flex items-center justify-between mx-auto px-4">
{/* Desktop Menu */}
<NavMenu className="hidden md:block" />
<div className="flex items-center gap-3">
{/* Social Media Links */}
<div className="flex items-center gap-3">
<a href="https://www.linkedin.com/in/craig-macfadyen-9a2041197/" target="_blank" rel="noopener noreferrer">
<SiLinkedin className="w-6 h-6 text-gray-700 dark:text-gray-300" />
</a>
<a href="https://github.com/cdmacfadyen" target="_blank" rel="noopener noreferrer">
<SiGithub className="w-6 h-6 text-gray-700 dark:text-gray-300" />
</a>
<a href="https://gitea.craigmacfadyen.co.uk/" target="_blank" rel="noopener noreferrer">
<SiGitea className="w-6 h-6 text-gray-700 dark:text-gray-300" />
</a>
</div>
{/* <Button
variant="outline"
className="hidden sm:inline-flex rounded-full"
>
Sign In
</Button> */}
{/* Mobile Menu */}
<div className="md:hidden">
<NavigationSheet />
</div>
</div>
</div>
</nav>
);
};
export default Navbar04Page;

View File

@@ -1,21 +0,0 @@
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Menu } from "lucide-react";
import { Logo } from "./logo";
import { NavMenu } from "./nav-menu";
export const NavigationSheet = () => {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="rounded-full">
<Menu />
</Button>
</SheetTrigger>
<SheetContent>
<Logo />
<NavMenu orientation="vertical" className="mt-12" />
</SheetContent>
</Sheet>
);
};

View File

@@ -1,119 +0,0 @@
"use client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Carousel,
CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
const testimonials = [
{
id: 1,
name: "Steven Adair",
designation: "Director",
company: "Managing Utilities Ltd",
testimonial:
"What set Craig apart was his ability to understand our business challenges and deliver a solution that worked for us. He didn't just build a model, he delivered a system our team could actually use."
},
];
const Testimonial06 = () => {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
<div className="w-full flex justify-center items-center px-6">
<div className="w-full">
<h2 className="mb-14 text-5xl md:text-6xl font-bold text-center tracking-tight">
Testimonials
</h2>
<div className="container w-full lg:max-w-screen-lg xl:max-w-screen-xl mx-auto px-12">
<Carousel setApi={setApi}>
<CarouselContent>
{testimonials.map((testimonial) => (
<CarouselItem key={testimonial.id}>
<TestimonialCard testimonial={testimonial} />
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
{/* <div className="flex items-center justify-center gap-2">
{Array.from({ length: count }).map((_, index) => (
<button
key={index}
onClick={() => api?.scrollTo(index)}
className={cn("h-3.5 w-3.5 rounded-full border-2", {
"bg-primary border-primary": current === index + 1,
})}
/>
))}
</div> */}
</div>
</div>
</div>
);
};
const TestimonialCard = ({
testimonial,
}: {
testimonial: (typeof testimonials)[number];
}) => (
<div className="mb-8 bg-accent rounded-xl py-8 px-6 sm:py-6">
<div className="flex items-center justify-between gap-20">
<div className="flex flex-col justify-center">
<div className="flex items-center justify-between gap-1">
<div className="hidden sm:flex md:hidden items-center gap-4">
<Avatar className="w-8 h-8 md:w-10 md:h-10">
<AvatarFallback className="text-xl font-medium bg-primary text-primary-foreground">
{testimonial.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-lg font-semibold">{testimonial.name}</p>
<p className="text-sm text-gray-500">{testimonial.designation}</p>
</div>
</div>
</div>
<p className="mt-6 text-lg sm:text-2xl lg:text-[1.75rem] xl:text-3xl leading-normal lg:!leading-normal font-semibold tracking-tight">
&quot;{testimonial.testimonial}&quot;
</p>
<div className="flex sm:hidden md:flex mt-6 items-center gap-4 justify-center">
<Avatar className="w-12 h-12 md:w-14 md:h-14">
<AvatarFallback className="text-3xl font-bold bg-primary text-primary-foreground">
{testimonial.name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="text-lg font-semibold">{testimonial.name}</p>
<p className="text-sm text-gray-500">{testimonial.designation}</p>
<p className="text-sm font-semibold">{testimonial.company}</p>
</div>
</div>
</div>
</div>
</div>
);
export default Testimonial06;

View File

@@ -1,51 +0,0 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -1,58 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button }

View File

@@ -1,241 +0,0 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -1,168 +0,0 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/50 data-[state=open]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@@ -1,139 +0,0 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -1,192 +0,0 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.87 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.87 0 0);
}
a {
font-weight: 500;
/* color: #646cff; */
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
color: #213547;
background-color: #f9f9f9;
}
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,31 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"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,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,13 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,17 +0,0 @@
import path from "path"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})