Initail_Commit

This commit is contained in:
2024-10-20 21:56:52 +02:00
commit ec47039eae
191 changed files with 21402 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
# This file is a placeholder for environment variables required to run this project.
# Currently, there are no environment variables needed.
# Example of what future variables might look like:
# NEXT_PUBLIC_API_KEY=your_api_key_here
# DATABASE_URL=your_database_url_here
# SECRET_KEY=your_secret_key_here

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
ko_fi: lorant

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

12
LICENSE Normal file
View File

@@ -0,0 +1,12 @@
Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)
This work is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
You are free to:
- Share — copy and redistribute the material in any medium or format.
- Adapt — remix, transform, and build upon the material.
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- NonCommercial — You may not use the material for commercial purposes.
For more details, view the full license at https://creativecommons.org/licenses/by-nc/4.0/legalcode.

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# **Build your portfolio with Once UI's Magic Portfolio**
Magic Portfolio was built with [Once UI](https://once-ui.com) for [Next.js](https://nextjs.org). It requires Node.js v18.17+.
**1. Clone the repository**
```
git clone https://github.com/once-ui-system/magic-portfolio.git
```
**2. Install dependencies**
```
npm install
```
**3. Run dev server**
```
npm run dev
```
**4. Edit config**
```
src/app/resources/config
```
**5. Edit content**
```
src/app/resources/content
```
**6. Create blog posts / projects**
```
Add a new .mdx file to src/app/blog/posts or src/app/work/projects
```
# **Features**
## **Once UI**
- All tokens, components & features of [Once UI](https://once-ui.com) (v0.3.1)
## **SEO**
- Automatic open-graph and X image generation with next/og
- Automatic schema and metadata generation based on the content file
## **Design**
- Responsive layout optimized for all screen sizes
- Timeless design without heavy animations and motion
- Endless customization options through [data attributes](https://once-ui.com/docs/theming)
TIP:
You try pre-built designs by changing the imports for the config and content in src/app/resources/index.ts
## **Content**
- Render sections conditionally based on the content file
- Enable or disable pages for blog, work, gallery and about / CV
- Generate and display social links automatically
- Set up password protection for URLs
# **Authors**
Connect with us on X or LinkedIn.
Lorant Toth: [X](https://x.com/lorant_one), [LinkedIn](https://www.linkedin.com/in/tothlorant/)
Zsofia Komaromi: [X](https://x.com/zsofiakomaromi), [LinkedIn](https://www.linkedin.com/in/zsofiakomaromi/)
# **Get involved**
- Join the [Once UI Discord server](https://discord.com/invite/5EyAQ4eNdS) and share your portfolio with designers and developers!
- Report a [bug](https://github.com/once-ui-system/magic-portfolio/issues/new?labels=bug&template=bug_report.md).
# **License**
Distributed under the CC BY-NC 4.0 License.
- Commercial usage is not allowed.
- Attribution is required.
See `LICENSE.txt` for more information.
# **Deploy with Vercel**
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fonce-ui-system%2Fmagic-portfolio&project-name=portfolio&repository-name=portfolio&redirect-url=https%3A%2F%2Fgithub.com%2Fonce-ui-system%2Fmagic-portfolio&demo-title=Magic%20Portfolio&demo-description=Showcase%20your%20designers%20or%20developer%20portfolio&demo-url=https%3A%2F%2Fdemo.magic-portfolio.com&demo-image=https%3A%2F%2Fonce-ui.com%2Fimages%2Ftemplates%2Fmagic-portfolio%2Fcover.jpg)

13
next.config.mjs Normal file
View File

@@ -0,0 +1,13 @@
import mdx from '@next/mdx';
const withMDX = mdx({
extension: /\.mdx?$/,
options: { },
});
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
};
export default withMDX(nextConfig);

6623
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "@once-ui-system/magic-portfolio",
"version": "0.1.0",
"scripts": {
"dev": "next dev",
"export": "next export",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@floating-ui/react-dom": "^2.1.1",
"@mdx-js/loader": "^3.0.1",
"@next/mdx": "^14.2.5",
"classnames": "^2.5.1",
"cookie": "^0.7.1",
"framer-motion": "^11.11.1",
"gray-matter": "^4.0.3",
"next": "^14.2.4",
"next-mdx-remote": "^5.0.0",
"postcss-preset-env": "^9.5.15",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.2.1",
"react-masonry-css": "^1.0.16",
"remark": "^15.0.1",
"remark-html": "^16.0.1",
"sass": "^1.77.6",
"sharp": "^0.33.4"
},
"devDependencies": {
"@csstools/postcss-global-data": "^2.1.1",
"@types/cookie": "^0.6.0",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"postcss-custom-media": "^10.0.7",
"postcss-flexbugs-fixes": "^5.0.2",
"tailwindcss": "^3.4.13",
"typescript": "^5"
}
}

26
postcss.config.js Normal file
View File

@@ -0,0 +1,26 @@
module.exports = {
"plugins": [
[
'@csstools/postcss-global-data',
{
files: ['src/once-ui/styles/breakpoints.scss'],
},
],
"postcss-custom-media",
"postcss-flexbugs-fixes",
[
"postcss-preset-env",
{
"autoprefixer": {
"flexbox": "no-2009"
},
"stage": 3,
"features": {
"custom-properties": false
}
}
],
"tailwindcss", // Toevoegen van Tailwind CSS
"autoprefixer", // Autoprefixer om CSS-compatibiliteit te verbeteren
]
};

BIN
public/fonts/Inter.ttf Normal file

Binary file not shown.

BIN
public/images/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

View File

@@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.34434 18.3679C4.03659 17.0602 4.03659 14.9399 5.34434 13.6321L9.06533 9.91113L14.4777 15.3235C14.8513 15.6971 14.8513 16.3029 14.4777 16.6766L9.06533 22.0889L5.34434 18.3679Z" fill="#B4D6FB"/>
<path d="M9.911 9.06545L13.632 5.34446C14.9397 4.03671 17.06 4.03671 18.3678 5.34446L22.0888 9.06545L16.6764 14.4778C16.3028 14.8514 15.697 14.8514 15.3233 14.4778L9.911 9.06545Z" fill="#B4D6FB"/>
<path d="M9.911 22.9346L15.3233 17.5222C15.697 17.1486 16.3028 17.1486 16.6764 17.5222L22.0888 22.9346L18.3678 26.6556C17.06 27.9633 14.9397 27.9633 13.632 26.6556L9.911 22.9346Z" fill="#B4D6FB"/>
<path d="M17.5221 16.6766C17.1485 16.3029 17.1485 15.6971 17.5221 15.3235L22.9345 9.91113L26.6554 13.6321C27.9632 14.9399 27.9632 17.0602 26.6554 18.3679L22.9345 22.0889L17.5221 16.6766Z" fill="#B4D6FB"/>
</svg>

After

Width:  |  Height:  |  Size: 907 B

View File

@@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.34434 18.3679C4.03659 17.0602 4.03659 14.9399 5.34434 13.6321L9.06533 9.91113L14.4777 15.3235C14.8513 15.6971 14.8513 16.3029 14.4777 16.6766L9.06533 22.0889L5.34434 18.3679Z" fill="#2D69FA"/>
<path d="M9.911 9.06545L13.632 5.34446C14.9397 4.03671 17.06 4.03671 18.3678 5.34446L22.0888 9.06545L16.6764 14.4778C16.3028 14.8514 15.697 14.8514 15.3233 14.4778L9.911 9.06545Z" fill="#2D69FA"/>
<path d="M9.911 22.9346L15.3233 17.5222C15.697 17.1486 16.3028 17.1486 16.6764 17.5222L22.0888 22.9346L18.3678 26.6556C17.06 27.9633 14.9397 27.9633 13.632 26.6556L9.911 22.9346Z" fill="#2D69FA"/>
<path d="M17.5221 16.6766C17.1485 16.3029 17.1485 15.6971 17.5221 15.3235L22.9345 9.91113L26.6554 13.6321C27.9632 14.9399 27.9632 17.0602 26.6554 18.3679L22.9345 22.0889L17.5221 16.6766Z" fill="#2D69FA"/>
</svg>

After

Width:  |  Height:  |  Size: 907 B

View File

@@ -0,0 +1,8 @@
<svg width="606" height="240" viewBox="0 0 606 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M95.4713 168.17C83.6559 168.17 74.7483 165.031 68.7483 158.754C62.8406 152.385 59.8867 143.062 59.8867 130.785C59.8867 117.585 62.8867 108.123 68.8867 102.4C74.979 96.5849 83.8406 93.6772 95.4713 93.6772C103.317 93.6772 109.871 95.0157 115.133 97.6926C120.487 100.277 124.502 104.293 127.179 109.739C129.948 115.185 131.333 122.2 131.333 130.785C131.333 143.062 128.241 152.385 122.056 158.754C115.871 165.031 107.01 168.17 95.4713 168.17ZM95.4713 151.693C98.8867 151.693 101.702 150.908 103.917 149.339C106.225 147.677 107.933 145.323 109.041 142.277C110.241 139.139 110.841 135.308 110.841 130.785C110.841 125.616 110.241 121.554 109.041 118.6C107.933 115.554 106.225 113.385 103.917 112.093C101.61 110.708 98.7944 110.016 95.4713 110.016C91.9636 110.016 89.1021 110.708 86.8867 112.093C84.6713 113.477 83.0098 115.693 81.9021 118.739C80.8867 121.693 80.379 125.708 80.379 130.785C80.379 137.8 81.579 143.062 83.979 146.57C86.4713 149.985 90.3021 151.693 95.4713 151.693Z" fill="white"/>
<path d="M145.573 101.985C147.327 101.062 149.634 100.139 152.496 99.2157C155.357 98.2003 158.496 97.3234 161.911 96.5849C165.327 95.8465 168.742 95.2465 172.157 94.7849C175.665 94.3234 178.896 94.0926 181.85 94.0926C188.127 94.0926 193.48 94.9234 197.911 96.5849C202.342 98.1542 205.711 100.739 208.019 104.339C210.419 107.939 211.619 112.831 211.619 119.016V166.093H190.988V123.17C190.988 121.416 190.711 119.8 190.157 118.323C189.696 116.846 188.957 115.6 187.942 114.585C186.927 113.477 185.542 112.646 183.788 112.093C182.034 111.446 179.911 111.123 177.419 111.123C175.296 111.123 173.173 111.4 171.05 111.954C168.067 112.602 162.716 114.555 160.032 115.923H166.204V166.093H145.573V101.985Z" fill="white"/>
<path d="M262.763 167.893C257.779 167.893 253.071 167.154 248.64 165.677C244.209 164.2 240.286 161.985 236.871 159.031C233.455 156.077 230.779 152.339 228.84 147.816C226.902 143.293 225.932 138.031 225.932 132.031C225.932 125.662 226.855 120.123 228.702 115.416C230.548 110.616 233.086 106.646 236.317 103.508C239.64 100.37 243.517 98.0157 247.948 96.4465C252.379 94.8772 257.271 94.0926 262.625 94.0926C266.409 94.0926 270.286 94.508 274.255 95.3388C278.317 96.0772 280.448 96.6542 283.494 98.0388L284.917 116.246C281.686 114.77 278.502 113.57 275.363 112.646C272.317 111.631 269.179 111.123 265.948 111.123C260.132 111.123 255.425 112.6 251.825 115.554C248.317 118.416 246.563 123.123 246.563 129.677C246.563 136.6 248.132 141.816 251.271 145.323C254.409 148.831 259.625 150.585 266.917 150.585C270.332 150.585 273.609 149.985 276.748 148.785C279.979 147.585 282.748 146.339 285.055 145.046L283.494 163.231C280.263 164.893 278.455 165.585 274.948 166.508C271.44 167.431 267.379 167.893 262.763 167.893Z" fill="white"/>
<path d="M331.157 168.17C324.141 168.17 317.91 166.739 312.464 163.877C307.11 160.923 302.91 156.77 299.864 151.416C296.91 145.97 295.434 139.508 295.434 132.031C295.434 119.846 298.434 110.431 304.434 103.785C310.526 97.0465 319.203 93.6772 330.464 93.6772C338.218 93.6772 344.449 95.3388 349.157 98.6619C353.957 101.985 357.464 106.831 359.68 113.2C361.895 119.57 362.957 127.323 362.864 136.462H309.557L307.341 123.308H316.466L315.5 126.5L316.068 129.5L319.263 123.308H345.834L342.787 129.816C342.695 122.339 341.634 117.077 339.603 114.031C337.572 110.985 334.249 109.462 329.634 109.462C327.049 109.462 324.695 110.108 322.572 111.4C320.541 112.693 318.926 114.862 317.726 117.908C316.618 120.862 316.064 124.97 316.064 130.231C316.064 136.877 317.634 142 320.772 145.6C324.003 149.2 329.126 151 336.141 151C338.726 151 341.264 150.723 343.757 150.17C346.341 149.523 348.741 148.785 350.957 147.954C353.172 147.123 355.064 146.385 356.634 145.739L355.609 163.231C352.286 164.616 349.572 165.954 345.418 166.785C341.357 167.708 336.603 168.17 331.157 168.17Z" fill="white"/>
<path d="M410.87 133.968L410.882 133.969V64C417.964 64 423.708 69.6896 423.708 76.7078L423.696 134.676C423.696 147.312 426.546 156.7 432.248 162.84C437.95 168.863 446.321 171.874 457.367 171.874C468.413 171.874 476.851 168.863 482.667 162.84C488.49 156.7 491.401 147.312 491.401 134.676V76.3547C491.401 69.5313 496.982 64 503.87 64V133.968C503.87 144.714 502.03 153.807 498.35 161.246C494.664 168.686 489.38 174.354 482.492 178.251C446.224 198.143 410.87 171.28 410.87 133.968Z" fill="#B4D6FB"/>
<path d="M533.87 76.6919V182C541.051 182 546.87 176.318 546.87 169.308V64C539.689 64 533.87 69.6824 533.87 76.6919Z" fill="#B4D6FB"/>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,8 @@
<svg width="606" height="240" viewBox="0 0 606 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M95.4713 168.17C83.6559 168.17 74.7483 165.031 68.7483 158.754C62.8406 152.385 59.8867 143.062 59.8867 130.785C59.8867 117.585 62.8867 108.123 68.8867 102.4C74.979 96.5849 83.8406 93.6772 95.4713 93.6772C103.317 93.6772 109.871 95.0157 115.133 97.6926C120.487 100.277 124.502 104.293 127.179 109.739C129.948 115.185 131.333 122.2 131.333 130.785C131.333 143.062 128.241 152.385 122.056 158.754C115.871 165.031 107.01 168.17 95.4713 168.17ZM95.4713 151.693C98.8867 151.693 101.702 150.908 103.917 149.339C106.225 147.677 107.933 145.323 109.041 142.277C110.241 139.139 110.841 135.308 110.841 130.785C110.841 125.616 110.241 121.554 109.041 118.6C107.933 115.554 106.225 113.385 103.917 112.093C101.61 110.708 98.7944 110.016 95.4713 110.016C91.9636 110.016 89.1021 110.708 86.8867 112.093C84.6713 113.477 83.0098 115.693 81.9021 118.739C80.8867 121.693 80.379 125.708 80.379 130.785C80.379 137.8 81.579 143.062 83.979 146.57C86.4713 149.985 90.3021 151.693 95.4713 151.693Z" fill="#0A071B"/>
<path d="M145.573 101.985C147.327 101.062 149.634 100.139 152.496 99.2157C155.357 98.2003 158.496 97.3234 161.911 96.5849C165.327 95.8465 168.742 95.2465 172.157 94.7849C175.665 94.3234 178.896 94.0926 181.85 94.0926C188.127 94.0926 193.48 94.9234 197.911 96.5849C202.342 98.1542 205.711 100.739 208.019 104.339C210.419 107.939 211.619 112.831 211.619 119.016V166.093H190.988V123.17C190.988 121.416 190.711 119.8 190.157 118.323C189.696 116.846 188.957 115.6 187.942 114.585C186.927 113.477 185.542 112.646 183.788 112.093C182.034 111.446 179.911 111.123 177.419 111.123C175.296 111.123 173.173 111.4 171.05 111.954C168.067 112.602 162.716 114.555 160.032 115.923H166.204V166.093H145.573V101.985Z" fill="#0A071B"/>
<path d="M262.763 167.893C257.779 167.893 253.071 167.154 248.64 165.677C244.209 164.2 240.286 161.985 236.871 159.031C233.455 156.077 230.779 152.339 228.84 147.816C226.902 143.293 225.932 138.031 225.932 132.031C225.932 125.662 226.855 120.123 228.702 115.416C230.548 110.616 233.086 106.646 236.317 103.508C239.64 100.37 243.517 98.0157 247.948 96.4465C252.379 94.8772 257.271 94.0926 262.625 94.0926C266.409 94.0926 270.286 94.508 274.255 95.3388C278.317 96.0772 280.448 96.6542 283.494 98.0388L284.917 116.246C281.686 114.77 278.502 113.57 275.363 112.646C272.317 111.631 269.179 111.123 265.948 111.123C260.132 111.123 255.425 112.6 251.825 115.554C248.317 118.416 246.563 123.123 246.563 129.677C246.563 136.6 248.132 141.816 251.271 145.323C254.409 148.831 259.625 150.585 266.917 150.585C270.332 150.585 273.609 149.985 276.748 148.785C279.979 147.585 282.748 146.339 285.055 145.046L283.494 163.231C280.263 164.893 278.455 165.585 274.948 166.508C271.44 167.431 267.379 167.893 262.763 167.893Z" fill="#0A071B"/>
<path d="M331.157 168.17C324.141 168.17 317.91 166.739 312.464 163.877C307.11 160.923 302.91 156.77 299.864 151.416C296.91 145.97 295.434 139.508 295.434 132.031C295.434 119.846 298.434 110.431 304.434 103.785C310.526 97.0465 319.203 93.6772 330.464 93.6772C338.218 93.6772 344.449 95.3388 349.157 98.6619C353.957 101.985 357.464 106.831 359.68 113.2C361.895 119.57 362.957 127.323 362.864 136.462H309.557L307.341 123.308H316.466L315.5 126.5L316.068 129.5L319.263 123.308H345.834L342.787 129.816C342.695 122.339 341.634 117.077 339.603 114.031C337.572 110.985 334.249 109.462 329.634 109.462C327.049 109.462 324.695 110.108 322.572 111.4C320.541 112.693 318.926 114.862 317.726 117.908C316.618 120.862 316.064 124.97 316.064 130.231C316.064 136.877 317.634 142 320.772 145.6C324.003 149.2 329.126 151 336.141 151C338.726 151 341.264 150.723 343.757 150.17C346.341 149.523 348.741 148.785 350.957 147.954C353.172 147.123 355.064 146.385 356.634 145.739L355.609 163.231C352.286 164.616 349.572 165.954 345.418 166.785C341.357 167.708 336.603 168.17 331.157 168.17Z" fill="#0A071B"/>
<path d="M410.87 133.968L410.882 133.969V64C417.964 64 423.708 69.6896 423.708 76.7078L423.696 134.676C423.696 147.312 426.546 156.7 432.248 162.84C437.95 168.863 446.321 171.874 457.367 171.874C468.413 171.874 476.851 168.863 482.667 162.84C488.49 156.7 491.401 147.312 491.401 134.676V76.3547C491.401 69.5313 496.982 64 503.87 64V133.968C503.87 144.714 502.03 153.807 498.35 161.246C494.664 168.686 489.38 174.354 482.492 178.251C446.224 198.143 410.87 171.28 410.87 133.968Z" fill="#2D69FA"/>
<path d="M533.87 76.6919V182C541.051 182 546.87 176.318 546.87 169.308V64C539.689 64 533.87 69.6824 533.87 76.6919Z" fill="#2D69FA"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,17 @@
.hover {
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
&:hover {
transform: translateX(var(--static-space-4));
}
}
@media (--s) {
.textAlign {
text-align: center;
}
.blockAlign {
align-self: center;
}
}

View File

@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Flex, Text } from '@/once-ui/components';
import styles from '@/app/about/about.module.scss';
interface TableOfContentsProps {
structure: {
title: string;
display: boolean;
items: string[];
}[];
about: {
tableOfContent: {
display: boolean;
subItems: boolean;
};
};
}
const TableOfContents: React.FC<TableOfContentsProps> = ({ structure, about }) => {
const scrollTo = (id: string, offset: number) => {
const element = document.getElementById(id);
if (element) {
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.scrollY - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth',
});
}
};
if (!about.tableOfContent.display) return null;
return (
<Flex
style={{
left: '0',
top: '50%',
transform: 'translateY(-50%)',
whiteSpace: 'nowrap'
}}
position="fixed"
paddingLeft="24" gap="32"
direction="column" hide="s">
{structure
.filter(section => section.display)
.map((section, sectionIndex) => (
<Flex key={sectionIndex} gap="12" direction="column">
<Flex
style={{ cursor: 'pointer' }}
className={styles.hover}
gap="8"
alignItems="center"
onClick={() => scrollTo(section.title, 80)}>
<Flex
height="1" width="16"
background="neutral-strong">
</Flex>
<Text>
{section.title}
</Text>
</Flex>
{about.tableOfContent.subItems && (
<>
{section.items.map((item, itemIndex) => (
<Flex
key={itemIndex}
style={{ cursor: 'pointer' }}
className={styles.hover}
gap="12" paddingLeft="24"
alignItems="center"
onClick={() => scrollTo(item, 80)}>
<Flex
height="1" width="8"
background="neutral-strong">
</Flex>
<Text>
{item}
</Text>
</Flex>
))}
</>
)}
</Flex>
))}
</Flex>
);
};
export default TableOfContents;

426
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,426 @@
import { Avatar, Button, Flex, Heading, Icon, IconButton, SmartImage, Tag, Text } from '@/once-ui/components';
import { person, about, social, baseURL } from '@/app/resources'
import TableOfContents from '@/app/about/components/TableOfContents';
import styles from '@/app/about/about.module.scss'
export function generateMetadata() {
const title = about.title;
const description = about.description;
const ogImage = `https://${baseURL}/og?title=${encodeURIComponent(title)}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
url: `https://${baseURL}/blog`,
images: [
{
url: ogImage,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
const structure = [
{
title: about.intro.title,
display: about.intro.display,
items: []
},
{
title: about.work.title,
display: about.work.display,
items: about.work.experiences.map(experience => experience.company)
},
{
title: about.studies.title,
display: about.studies.display,
items: about.studies.institutions.map(institution => institution.name)
},
{
title: about.technical.title,
display: about.technical.display,
items: about.technical.skills.map(skill => skill.title)
},
{
title: about.certificates.title,
display: about.certificates.display,
items: about.certificates.certs.map(certs => certs.title)
},
]
export default function About() {
return (
<Flex
fillWidth maxWidth="m"
direction="column">
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Person',
name: person.name,
jobTitle: person.role,
description: about.intro.description,
url: `https://${baseURL}/about`,
image: `${baseURL}/images/${person.avatar}`,
sameAs: social
.filter((item) => item.link && !item.link.startsWith('mailto:')) // Filter out empty links and email links
.map((item) => item.link),
worksFor: {
'@type': 'Organization',
name: about.work.experiences[0].company || ''
},
}),
}}
/>
{ about.tableOfContent.display && (
<Flex
style={{ left: '0', top: '50%', transform: 'translateY(-50%)' }}
position="fixed"
paddingLeft="24" gap="32"
direction="column" hide="s">
<TableOfContents
structure={structure}
about={about} />
</Flex>
)}
<Flex
fillWidth
mobileDirection="column" justifyContent="center">
{ about.avatar.display && (
<Flex
minWidth="160" paddingX="l" paddingBottom="xl" gap="m"
flex={3} direction="column" alignItems="center">
<Avatar
src={person.avatar}
size="xl"/>
<Flex
gap="8"
alignItems="center">
<Icon
onBackground="accent-weak"
name="globe"/>
{person.location}
</Flex>
{ person.languages.length > 0 && (
<Flex
wrap
gap="8">
{person.languages.map((language, index) => (
<Tag
key={index}
size="l">
{language}
</Tag>
))}
</Flex>
)}
</Flex>
)}
<Flex
className={styles.blockAlign}
fillWidth flex={9} maxWidth={40} direction="column">
<Flex
id={about.intro.title}
fillWidth minHeight="160"
direction="column" justifyContent="center"
marginBottom="32">
{about.calendar.display && (
<Flex
className={styles.blockAlign}
style={{
backdropFilter: 'blur(var(--static-space-1))',
border: '1px solid var(--brand-alpha-medium)',
width: 'fit-content'
}}
alpha="brand-weak" radius="full"
fillWidth padding="4" gap="8" marginBottom="m"
alignItems="center">
<Flex paddingLeft="12">
<Icon
name="calendar"
onBackground="brand-weak"/>
</Flex>
<Flex
paddingX="8">
Schedule a call
</Flex>
<IconButton
href={about.calendar.link}
data-border="rounded"
variant="tertiary"
icon="chevronRight"/>
</Flex>
)}
<Heading
className={styles.textAlign}
variant="display-strong-l">
{person.name}
</Heading>
<Text
className={styles.textAlign}
variant="display-default-xs"
onBackground="neutral-weak">
{person.role}
</Text>
{social.length > 0 && (
<Flex
className={styles.blockAlign}
paddingTop="20" paddingBottom="8" gap="8" wrap>
{social.map((item) => (
item.link && (
<Button
key={item.name}
href={item.link}
prefixIcon={item.icon}
label={item.name}
size="s"
variant="tertiary"/>
)
))}
</Flex>
)}
</Flex>
{ about.intro.display && (
<Flex
direction="column"
textVariant="body-default-l"
fillWidth gap="m" marginBottom="xl">
{about.intro.description}
</Flex>
)}
{ about.work.display && (
<>
<Heading
as="h2"
id={about.work.title}
variant="display-strong-s"
marginBottom="m">
{about.work.title}
</Heading>
<Flex
direction="column"
fillWidth gap="l" marginBottom="40">
{about.work.experiences.map((experience, index) => (
<Flex
key={`${experience.company}-${experience.role}-${index}`}
fillWidth
direction="column">
<Flex
fillWidth
justifyContent="space-between"
alignItems="flex-end"
marginBottom="4">
<Text
id={experience.company}
variant="heading-strong-l">
{experience.company}
</Text>
<Text
variant="heading-default-xs"
onBackground="neutral-weak">
{experience.timeframe}
</Text>
</Flex>
<Text
variant="body-default-s"
onBackground="brand-weak"
marginBottom="m">
{experience.role}
</Text>
<Flex
as="ul"
direction="column" gap="16">
{experience.achievements.map((achievement, index) => (
<Text
as="li"
variant="body-default-m"
key={`${experience.company}-${index}`}>
{achievement}
</Text>
))}
</Flex>
{experience.images.length > 0 && (
<Flex
fillWidth paddingTop="m" paddingLeft="40"
wrap>
{experience.images.map((image, index) => (
<Flex
key={index}
border="neutral-medium"
borderStyle="solid-1"
radius="m"
minWidth={image.width} height={image.height}>
<SmartImage
enlarge
radius="m"
sizes={image.width.toString()}
alt={image.alt}
src={image.src}/>
</Flex>
))}
</Flex>
)}
</Flex>
))}
</Flex>
</>
)}
{ about.studies.display && (
<>
<Heading
as="h2"
id={about.studies.title}
variant="display-strong-s"
marginBottom="m">
{about.studies.title}
</Heading>
<Flex
direction="column"
fillWidth gap="l" marginBottom="40">
{about.studies.institutions.map((institution, index) => (
<Flex
key={`${institution.name}-${index}`}
fillWidth gap="4"
direction="column">
<Text
id={institution.name}
variant="heading-strong-l">
{institution.name}
</Text>
<Text
variant="heading-default-xs"
onBackground="neutral-weak">
{institution.description}
</Text>
</Flex>
))}
</Flex>
</>
)}
{ about.technical.display && (
<>
<Heading
as="h2"
id={about.technical.title}
variant="display-strong-s" marginBottom="40">
{about.technical.title}
</Heading>
<Flex
direction="column"
fillWidth gap="l" marginBottom="40">
{about.technical.skills.map((skill, index) => (
<Flex
key={`${skill}-${index}`}
fillWidth gap="4"
direction="column">
<Text
variant="heading-strong-l">
{skill.title}
</Text>
<Text
variant="body-default-m"
onBackground="neutral-weak">
{skill.description}
</Text>
{skill.images.length > 0 && (
<Flex
fillWidth paddingTop="m" gap="12"
wrap>
{skill.images.map((image, index) => (
<Flex
key={index}
border="neutral-medium"
borderStyle="solid-1"
radius="m"
minWidth={image.width} height={image.height}>
<SmartImage
enlarge
radius="m"
sizes={image.width.toString()}
alt={image.alt}
src={image.src}/>
</Flex>
))}
</Flex>
)}
</Flex>
))}
</Flex>
</>
)}
{ about.certificates.display && (
<>
<Heading
as="h2"
id={about.certificates.title}
variant="display-strong-s" marginBottom="40">
{about.certificates.title}
</Heading>
<Flex
direction="column"
fillWidth gap="l">
{about.certificates.certs.map((cert, index) => (
<Flex
key={`${cert}-${index}`}
fillWidth gap="4"
direction="column">
<Text
variant="heading-strong-l">
{cert.title}
</Text>
<Text
variant="body-default-m"
onBackground="neutral-weak">
{cert.description}
</Text>
{cert.images.length > 0 && (
<Flex
fillWidth paddingTop="m" gap="12"
wrap>
{cert.images.map((image, index) => (
<Flex
key={index}
border="neutral-medium"
borderStyle="solid-1"
radius="m"
minWidth={image.width} height={image.height}>
<SmartImage
enlarge
radius="m"
sizes={image.width.toString()}
alt={image.alt}
src={image.src}/>
</Flex>
))}
</Flex>
)}
</Flex>
))}
</Flex>
</>
)}
</Flex>
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1,130 @@
import { notFound } from 'next/navigation'
import { CustomMDX } from '@/app/components/mdx'
import { formatDate, getPosts } from '@/app/utils'
import { Avatar, Button, Flex, Heading, Text } from '@/once-ui/components'
import { person, baseURL } from '@/app/resources'
interface BlogParams {
params: {
slug: string;
};
}
export async function generateStaticParams() {
let posts = getPosts(['src', 'app', 'blog', 'posts'])
return posts.map((post) => ({
slug: post.slug,
}))
}
export function generateMetadata({ params }: BlogParams) {
let post = getPosts(['src', 'app', 'blog', 'posts']).find((post) => post.slug === params.slug)
if (!post) {
return
}
let {
title,
publishedAt: publishedTime,
summary: description,
image,
} = post.metadata;
let ogImage = image
? `https://${baseURL}${image}`
: `https://${baseURL}/og?title=${title}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime,
url: `https://${baseURL}/blog/${post.slug}`,
images: [
{
url: ogImage,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
}
}
export default function Blog({ params }: BlogParams) {
let post = getPosts(['src', 'app', 'blog', 'posts']).find((post) => post.slug === params.slug)
if (!post) {
notFound()
}
return (
<Flex as="section"
fillWidth maxWidth="xs"
direction="column"
gap="m">
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.metadata.title,
datePublished: post.metadata.publishedAt,
dateModified: post.metadata.publishedAt,
description: post.metadata.summary,
image: post.metadata.image
? `https://${baseURL}${post.metadata.image}`
: `https://${baseURL}/og?title=${post.metadata.title}`,
url: `https://${baseURL}/blog/${post.slug}`,
author: {
'@type': 'Person',
name: person.name,
},
}),
}}
/>
<Button
href="/blog"
variant="tertiary"
size="s"
prefixIcon="chevronLeft">
Posts
</Button>
<Heading
variant="display-strong-s">
{post.metadata.title}
</Heading>
<Flex
gap="12"
alignItems="center">
{ person.avatar && (
<Avatar
size="s"
src={person.avatar}/>
)}
<Text
variant="body-default-s"
onBackground="neutral-weak">
{formatDate(post.metadata.publishedAt)}
</Text>
</Flex>
<Flex
as="article"
direction="column"
fillWidth>
<CustomMDX source={post.content} />
</Flex>
</Flex>
)
}

View File

@@ -0,0 +1,17 @@
.hover {
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
&:hover {
transform: translateX(var(--static-space-8));
.indicator {
transform: rotate(0);
}
}
}
.indicator {
transform: rotate(-90deg);
left: -2rem;
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
}

View File

@@ -0,0 +1,67 @@
import { formatDate, getPosts } from '@/app/utils';
import { Flex, Grid, Heading, SmartLink, Text } from '@/once-ui/components';
import styles from '@/app/blog/components/Posts.module.scss';
interface PostsProps {
range?: [number] | [number, number];
columns?: '1' | '2' | '3';
}
export function Posts({
range,
columns = '1'
}: PostsProps) {
let allBlogs = getPosts(['src', 'app', 'blog', 'posts']);
const sortedBlogs = allBlogs.sort((a, b) => {
return new Date(b.metadata.publishedAt).getTime() - new Date(a.metadata.publishedAt).getTime();
});
const displayedBlogs = range
? sortedBlogs.slice(
range[0] - 1,
range.length === 2 ? range[1] : sortedBlogs.length
)
: sortedBlogs;
return (
<>
{ displayedBlogs.length > 0 && (
<Grid
columns={`repeat(${columns}, 1fr)`} mobileColumns="1col"
fillWidth marginBottom="40" gap="m" paddingX="l">
{displayedBlogs.map((post) => (
<SmartLink
style={{
textDecoration: 'none',
margin: '0',
height: 'fit-content',
}}
className={styles.hover}
key={post.slug}
href={`/blog/${post.slug}`}>
<Flex
position="relative"
paddingX="16" paddingY="12" gap="8"
direction="column" justifyContent="center">
<Flex
position="absolute"
className={styles.indicator}
width="20" height="2"
background="neutral-strong"/>
<Heading as="h2" wrap="balance">
{post.metadata.title}
</Heading>
<Text
variant="body-default-s"
onBackground="neutral-weak">
{formatDate(post.metadata.publishedAt, false)}
</Text>
</Flex>
</SmartLink>
))}
</Grid>
)}
</>
);
}

79
src/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,79 @@
import { Flex, Heading } from '@/once-ui/components';
import { Mailchimp } from '@/app/components';
import { Posts } from '@/app/blog/components/Posts';
import { blog, newsletter, person } from '@/app/resources'
import { baseURL, mailchimp } from '@/app/resources'
export function generateMetadata() {
const title = blog.title;
const description = blog.description;
const ogImage = `https://${baseURL}/og?title=${encodeURIComponent(title)}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
url: `https://${baseURL}/blog`,
images: [
{
url: ogImage,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
export default function Blog() {
return (
<Flex
fillWidth maxWidth="s"
direction="column">
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Blog',
headline: blog.title,
description: blog.description,
url: `https://${baseURL}/blog`,
image: `${baseURL}/og?title=${encodeURIComponent(blog.title)}`,
author: {
'@type': 'Person',
name: person.name,
image: {
'@type': 'ImageObject',
url: `${baseURL}${person.avatar}`,
},
},
}),
}}
/>
<Heading
marginBottom="l"
variant="display-strong-s">
{blog.title}
</Heading>
<Flex
fillWidth flex={1}>
<Posts range={[1,3]}/>
<Posts range={[4]} columns="2"/>
</Flex>
{newsletter.display && (
<Mailchimp/>
)}
</Flex>
);
}

View File

@@ -0,0 +1,33 @@
---
title: "Arriving to a new milestone in my career"
publishedAt: "2024-04-08"
summary: "Every career is a journey, filled with challenges, growth, and those significant moments that mark a shift in our path."
---
Every career is a journey, filled with challenges, growth, and those significant moments that mark a shift in our path. Today, Im excited to share that Ive arrived at a new milestone in my career—one that is both a culmination of past experiences and a stepping stone toward future aspirations.
## Reflecting on the Journey So Far
Looking back, its clear that each phase of my career has built upon the last, guiding me to where I stand today. From my early days of learning the basics and struggling through complex projects, to gaining confidence through real-world experience, each step has shaped my skills and mindset. Ive learned that design and engineering are as much about problem-solving and creativity as they are about resilience and continuous learning.
## What This Milestone Represents
Reaching this milestone represents more than just professional progress—its a moment of personal growth. Its a sign that the dedication and passion Ive invested are paying off. Whether its mastering a new skill, taking on leadership responsibilities, or completing a major project, this achievement is a reminder that perseverance, curiosity, and a love for what I do are key drivers of success.
## The Challenges That Shaped Me
Of course, no journey is without its hurdles. There were moments of doubt, failed prototypes, and unforeseen obstacles that tested my resolve. However, those challenges taught me the importance of adaptability, creative thinking, and collaboration. They pushed me to improve, to think outside the box, and to view setbacks not as failures, but as opportunities to learn and grow.
## Embracing New Opportunities
This milestone is also an opportunity to embrace new challenges and expand my horizons. Whether its exploring emerging technologies, diving deeper into specific fields of interest, or taking on a mentorship role, Im excited about what lies ahead. The engineering and design landscape is constantly evolving, and staying curious and open to new ideas is what keeps this career path so rewarding.
## Gratitude and Acknowledgment
Id be remiss if I didnt take a moment to acknowledge the mentors, colleagues, and collaborators who have been part of this journey. Their insights, support, and shared enthusiasm have been invaluable. Reaching this milestone is as much a testament to their influence as it is to my individual efforts.
## Looking Ahead
While Im proud of how far Ive come, I know this is just one milestone in a much larger journey. The road ahead is filled with exciting possibilities, and Im eager to continue pushing boundaries, learning new things, and contributing to meaningful projects. If theres one thing Ive learned along the way, its that every new milestone is not an end, but rather a launchpad for the next chapter.
Thank you for being part of this journey with me, and heres to the adventures yet to come!

View File

@@ -0,0 +1,29 @@
---
title: "The 99% that remains in the drawer"
publishedAt: "2024-03-05"
summary: "As design engineers, we're often defined by the 1% of our work that makes it into the final product."
---
As design engineers, we're often defined by the 1% of our work that makes it into the final product. That shiny, polished piece of engineering is a result of countless iterations, tweaks, and redesigns. But what happens to the other 99%—those ideas, concepts, and prototypes that never see the light of day? They remain tucked away in the drawer, both literally and figuratively.
## The Beauty of Unused Ideas
Its easy to think of discarded designs as failures, but in truth, theyre stepping stones. Each one represents a path explored, tested, and ultimately left behind. Those early drafts may never become reality, but they play a crucial role in shaping the solution that does. They teach us what works and, more importantly, what doesnt.
## The Process of Elimination
In every project, the first few ideas often come quickly. Theyre intuitive, straightforward, and sometimes too simple. As we dive deeper, we explore more creative solutions, test the limits of materials and technology, and challenge the initial assumptions. This process of elimination is not about rejecting ideas but about refining them. The 99% left in the drawer is evidence of rigorous thinking and thorough exploration.
## Why the Drawer Matters
For every concept that didnt make it, theres a lesson learned. A sketch that looked promising might fail in prototyping. A concept that seemed impractical might be revisited years later, finding new relevance with advancements in technology or a change in project scope. These shelved ideas serve as a knowledge base—a library of possibilities for future projects.
## Innovation Through Failure
Many breakthrough innovations are born from revisiting old, seemingly failed concepts. What didnt work in one context might be the key to solving a problem in another. As design engineers, we should never be afraid to open the drawer and revisit those shelved ideas. They are a testament to the iterative nature of design, where nothing is truly wasted.
![Once UI logo on dark background.](/images/projects/project-01/cover-01.jpg)
## Final Thoughts
The final product is just the tip of the iceberg—the visible 1%. The other 99% may never see the spotlight, but they are just as important. They represent the trial and error, the persistence, and the creative drive that push us to find the best solution. So next time youre stuck or looking for inspiration, dont be afraid to dig into the drawer. The answer might be hiding there, waiting for the right moment to shine.

View File

@@ -0,0 +1,32 @@
---
title: "The rise of design engineering"
publishedAt: "2024-03-05"
summary: "In recent years, the role of design engineering has evolved from a specialized niche to a critical component in the development of innovative products and solutions."
---
The Rise of Design Engineering
In recent years, the role of design engineering has evolved from a specialized niche to a critical component in the development of innovative products and solutions. The seamless integration of design principles with engineering expertise has become essential in shaping not only the functionality of products but also the user experience, sustainability, and overall impact. So, whats driving this shift, and why is design engineering more relevant than ever before?
## Where Engineering Meets Creativity
Traditionally, engineering and design were viewed as separate disciplines. Engineers focused on solving technical problems, while designers were concerned with aesthetics and user experience. However, as products become more complex and user-centered, the need for a unified approach has grown. Design engineering bridges this gap by combining the precision of engineering with the creativity of design. Its where form meets function, ensuring that products are not only technically sound but also intuitive, visually appealing, and user-friendly.
## The User-Centered Revolution
One of the biggest factors in the rise of design engineering is the shift toward user-centered design. Whether its a smartphone, a medical device, or an automotive system, todays products are expected to be intuitive, responsive, and aligned with user needs. Design engineers play a pivotal role in this transformation by focusing on the end-user from the very beginning of the development process. Instead of approaching design and engineering as separate stages, they merge them into a cohesive workflow that considers usability, ergonomics, and aesthetics alongside structural integrity and functionality.
## Sustainability and Innovation
As the world becomes increasingly conscious of environmental impacts, design engineers are at the forefront of creating sustainable solutions. From selecting eco-friendly materials to designing for energy efficiency and minimizing waste, their work is crucial in driving sustainability initiatives across industries. The role of the design engineer extends beyond merely meeting technical requirements; it involves finding innovative ways to achieve sustainability without compromising on performance or aesthetics.
## The Digital Transformation
The digital revolution has also played a significant role in the rise of design engineering. Advanced tools such as computer-aided design (CAD) software, simulation, and rapid prototyping have empowered design engineers to push boundaries and experiment with ideas that were previously impossible. Virtual testing and iterative development processes allow for quick adjustments and refinements, enabling more sophisticated and optimized designs. This integration of digital technology with traditional engineering practices has made design engineering a dynamic and rapidly evolving field.
## Collaboration and Interdisciplinary Work
In the modern product development landscape, collaboration is key. Design engineering brings together experts from various fields—mechanical engineering, industrial design, electronics, materials science, and more—into a cohesive team. This interdisciplinary approach fosters innovation by allowing for a broader perspective on problems and solutions. Design engineers often act as the glue that holds these teams together, ensuring that everyones contributions align to create a product that is both technically sound and user-centric.
## Looking Ahead
The rise of design engineering signals a fundamental shift in how we approach product development. Its no longer enough to have a product that simply works; it must also resonate with users, be sustainable, and push the boundaries of innovation. As technology continues to advance and user expectations evolve, design engineers will be increasingly critical in shaping the future. Their unique blend of creative thinking and technical expertise will continue to drive the development of products that are not only functional but also meaningful.

View File

@@ -0,0 +1,48 @@
import { Flex, IconButton, SmartLink, Text } from "@/once-ui/components"
import { person, social } from '@/app/resources'
export const Footer = () => {
const currentYear = new Date().getFullYear();
return (
<Flex
as="footer"
position="relative"
fillWidth padding="8"
justifyContent="center">
<Flex
fillWidth maxWidth="m" paddingY="8" paddingX="16"
justifyContent="space-between" alignItems="center">
<Text
variant="body-default-s"
onBackground="neutral-strong">
<Text
onBackground="neutral-weak">
© {currentYear} /
</Text>
<Text paddingX="4">
{person.name}
</Text>
<Text onBackground="neutral-weak">
{/* Usage of this template requires attribution. Please don't remove the link to Once UI. */}
{/* / Build your portfolio with <SmartLink style={{marginLeft: '-0.125rem'}} href="https://once-ui.com/templates/magic-portfolio">Once UI</SmartLink> */}
</Text>
</Text>
<Flex
gap="16">
{social.map((item) => (
item.link && (
<IconButton
key={item.name}
href={item.link}
icon={item.icon}
tooltip={item.name}
size="s"
variant="ghost"/>
)
))}
</Flex>
</Flex>
</Flex>
)
}

View File

@@ -0,0 +1,12 @@
.position {
position: sticky;
top: 0;
}
@media (--s) {
.position {
top: auto;
position: fixed;
bottom: var(--static-space-24);
}
}

View File

@@ -0,0 +1,128 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Flex, ToggleButton } from "@/once-ui/components"
import styles from '@/app/components/Header.module.scss'
import { routes, display } from '@/app/resources'
import { person, home, about, blog, work, gallery } from '@/app/resources'
type TimeDisplayProps = {
timeZone: string;
locale?: string; // Optionally allow locale, defaulting to 'en-GB'
};
const TimeDisplay: React.FC<TimeDisplayProps> = ({ timeZone, locale = 'en-GB' }) => {
const [currentTime, setCurrentTime] = useState('');
useEffect(() => {
const updateTime = () => {
const now = new Date();
const options: Intl.DateTimeFormatOptions = {
timeZone,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
};
const timeString = new Intl.DateTimeFormat(locale, options).format(now);
setCurrentTime(timeString);
};
updateTime();
const intervalId = setInterval(updateTime, 1000);
return () => clearInterval(intervalId);
}, [timeZone, locale]);
return (
<>
{currentTime}
</>
);
};
export default TimeDisplay;
export const Header = () => {
const pathname = usePathname() ?? '';
return (
<Flex style={{height: 'fit-content'}}
className={styles.position}
as="header"
zIndex={9}
fillWidth padding="8"
justifyContent="center">
<Flex
hide="s"
paddingLeft="12" fillWidth
alignItems="center"
textVariant="body-default-s">
{ display.location && (
<>{person.location}</>
)}
</Flex>
<Flex
background="surface" border="neutral-medium" borderStyle="solid-1" radius="m-4" shadow="l"
padding="4"
justifyContent="center">
<Flex
gap="4"
textVariant="body-default-s">
{ routes['/'] && (
<ToggleButton
prefixIcon="home"
href="/"
selected={pathname === "/"}>
<Flex paddingX="2" hide="s">{home.label}</Flex>
</ToggleButton>
)}
{ routes['/about'] && (
<ToggleButton
prefixIcon="person"
href="/about"
selected={pathname === "/about"}>
<Flex paddingX="2" hide="s">{about.label}</Flex>
</ToggleButton>
)}
{ routes['/work'] && (
<ToggleButton
prefixIcon="grid"
href="/work"
selected={pathname.startsWith('/work')}>
<Flex paddingX="2" hide="s">{work.label}</Flex>
</ToggleButton>
)}
{ routes['/blog'] && (
<ToggleButton
prefixIcon="book"
href="/blog"
selected={pathname.startsWith('/blog')}>
<Flex paddingX="2" hide="s">{blog.label}</Flex>
</ToggleButton>
)}
{ routes['/gallery'] && (
<ToggleButton
prefixIcon="gallery"
href="/gallery"
selected={pathname.startsWith('/gallery')}>
<Flex paddingX="2" hide="s">{gallery.label}</Flex>
</ToggleButton>
)}
</Flex>
</Flex>
<Flex
hide="s"
paddingRight="12" fillWidth
justifyContent="flex-end" alignItems="center"
textVariant="body-default-s">
{ display.time && (
<TimeDisplay timeZone={person.location}/>
)}
</Flex>
</Flex>
)
}

View File

@@ -0,0 +1,24 @@
.control {
cursor: pointer;
&:hover {
.visibility {
opacity: 1;
}
.text {
text-decoration-line: underline;
}
}
}
.text {
text-decoration-thickness: 1px;
text-underline-offset: 0.25em;
text-decoration-color: var(--neutral-border-strong);
}
.visibility {
opacity: 0;
transform: scale(0.875);
}

View File

@@ -0,0 +1,87 @@
"use client";
import React, { useState, useCallback } from 'react';
import { Heading, Flex, IconButton, Toaster } from '@/once-ui/components';
import styles from '@/app/components/HeadingLink.module.scss';
interface HeadingLinkProps {
id: string;
level: 1 | 2 | 3 | 4 | 5 | 6;
children: React.ReactNode;
style?: React.CSSProperties;
}
export const HeadingLink: React.FC<HeadingLinkProps> = ({
id,
level,
children,
style
}) => {
const [toasts, setToasts] = useState<
{ id: string; variant: 'success' | 'danger'; message: string; action?: React.ReactNode }[]
>([]);
const addToast = useCallback(
(variant: 'success' | 'danger', message: string, action?: React.ReactNode) => {
const id = `${new Date().getTime()}`;
setToasts((prevToasts) => [...prevToasts, { id, variant, message, action }]);
},
[]
);
const removeToast = useCallback(
(id: string) => {
setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id));
},
[]
);
const copyURL = (id: string): void => {
const url = `${window.location.origin}${window.location.pathname}#${id}`;
navigator.clipboard.writeText(url).then(() => {
addToast('success', 'Link copied to clipboard.');
}, () => {
addToast('danger', 'Failed to copy link.');
});
};
const variantMap = {
1: 'heading-strong-xl',
2: 'heading-strong-xl',
3: 'heading-strong-l',
4: 'heading-strong-m',
5: 'heading-strong-s',
6: 'heading-strong-xs',
} as const;
const variant = variantMap[level];
const asTag = `h${level}` as keyof JSX.IntrinsicElements;
return (
<Flex>
<Toaster toasts={toasts} removeToast={removeToast}/>
<Flex
style={style}
onClick={() => copyURL(id)}
className={styles.control}
alignItems="center"
gap="4">
<Heading
className={styles.text}
id={id}
variant={variant}
as={asTag}>
{children}
</Heading>
<IconButton
className={styles.visibility}
size="s"
icon="openLink"
variant="ghost"
tooltip="Copy"
tooltipPosition="right" />
</Flex>
</Flex>
);
};

View File

@@ -0,0 +1,134 @@
"use client";
import { mailchimp } from '@/app/resources'
import { newsletter } from '@/app/resources'
import { Button, Flex, Heading, Input, Text } from '@/once-ui/components';
import { Background } from '@/once-ui/components/Background';
import { useState } from 'react';
function debounce<T extends (...args: any[]) => void>(func: T, delay: number): T {
let timeout: ReturnType<typeof setTimeout>;
return ((...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), delay);
}) as T;
}
export const Mailchimp = () => {
const [email, setEmail] = useState<string>('');
const [error, setError] = useState<string>('');
const [touched, setTouched] = useState<boolean>(false);
const validateEmail = (email: string): boolean => {
if (email === '') {
return true;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
if (!validateEmail(value)) {
setError('Please enter a valid email address.');
} else {
setError('');
}
};
const debouncedHandleChange = debounce(handleChange, 2000);
const handleBlur = () => {
setTouched(true);
if (!validateEmail(email)) {
setError('Please enter a valid email address.');
}
};
return (
<Flex
style={{overflow: 'hidden'}}
position="relative"
fillWidth padding="l" radius="l" marginBottom="m"
direction="column" alignItems="center" align="center"
background="surface" border="neutral-medium" borderStyle="solid-1">
<Background
position="absolute"
gradient={mailchimp.effects.gradient}
dots={mailchimp.effects.dots}
lines={mailchimp.effects.lines}/>
<Heading style={{position: 'relative'}}
marginBottom="s"
variant="display-strong-xs">
{newsletter.title}
</Heading>
<Text
style={{
position: 'relative',
maxWidth: 'var(--responsive-width-xs)'
}}
wrap="balance"
marginBottom="l"
onBackground="neutral-medium">
{newsletter.description}
</Text>
<form
style={{
width: '100%',
display: 'flex',
justifyContent: 'center'
}}
action={mailchimp.action}
method="post"
id="mc-embedded-subscribe-form"
name="mc-embedded-subscribe-form">
<Flex id="mc_embed_signup_scroll"
fillWidth maxWidth={24} gap="8">
<Input
formNoValidate
labelAsPlaceholder
id="mce-EMAIL"
name="EMAIL"
type="email"
label="Email"
required
onChange={(e) => {
if (error) {
handleChange(e);
} else {
debouncedHandleChange(e);
}
}}
onBlur={handleBlur}
error={error}/>
<div style={{display: 'none'}}>
<input type="checkbox" readOnly name="group[3492][1]" id="mce-group[3492]-3492-0" value="" checked/>
</div>
<div id="mce-responses" className="clearfalse">
<div className="response" id="mce-error-response" style={{display: 'none'}}></div>
<div className="response" id="mce-success-response" style={{display: 'none'}}></div>
</div>
<div aria-hidden="true" style={{position: 'absolute', left: '-5000px'}}>
<input type="text" readOnly name="b_c1a5a210340eb6c7bff33b2ba_0462d244aa" tabIndex={-1} value=""/>
</div>
<div className="clear">
<Flex
height="48" alignItems="center">
<Button
id="mc-embedded-subscribe"
value="Subscribe"
size="m"
fillWidth>
Sign up
</Button>
</Flex>
</div>
</Flex>
</form>
</Flex>
)
}

View File

@@ -0,0 +1,153 @@
"use client";
import { AvatarGroup, Flex, Heading, RevealFx, SmartImage, SmartLink, Text } from "@/once-ui/components";
import { useEffect, useState } from "react";
interface ProjectCardProps {
href: string;
images: string[];
title: string;
content: string;
description: string;
avatars: { src: string }[];
}
export const ProjectCard: React.FC<ProjectCardProps> = ({
href,
images = [],
title,
content,
description,
avatars
}) => {
const [activeIndex, setActiveIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setIsTransitioning(true);
}, 1000);
return () => clearTimeout(timer);
}, []);
const handleImageClick = () => {
if(images.length > 1) {
setIsTransitioning(false);
setTimeout(() => {
const nextIndex = (activeIndex + 1) % images.length;
setActiveIndex(nextIndex);
setTimeout(() => {
setIsTransitioning(true);
}, 630);
}, 630);
}
};
const handleControlClick = (index: number) => {
if (index !== activeIndex) {
setIsTransitioning(true);
setTimeout(() => {
setActiveIndex(index);
setTimeout(() => {
setIsTransitioning(false);
}, 630);
}, 630);
}
};
return (
<Flex
fillWidth gap="m"
direction="column">
<Flex onClick={handleImageClick}>
<RevealFx
style={{width: '100%'}}
delay={0.4}
trigger={isTransitioning}
speed="fast">
<SmartImage
tabIndex={0}
radius="l"
alt={title}
aspectRatio="16 / 9"
src={images[activeIndex]}
style={{
border: '1px solid var(--neutral-alpha-weak)',
...(images.length > 1 && {
cursor: 'pointer',
}),
}}/>
</RevealFx>
</Flex>
{images.length > 1 && (
<Flex
gap="4" paddingX="s"
fillWidth maxWidth={32}
justifyContent="center">
{images.map((_, index) => (
<Flex
key={index}
onClick={() => handleControlClick(index)}
style={{
background: activeIndex === index
? 'var(--neutral-on-background-strong)'
: 'var(--neutral-alpha-medium)',
cursor: 'pointer',
transition: 'background 0.3s ease',
}}
fillWidth
height="2">
</Flex>
))}
</Flex>
)}
<Flex
mobileDirection="column"
fillWidth paddingX="l" paddingTop="xs" paddingBottom="m" gap="l">
{title && (
<Flex
flex={5}>
<Heading
as="h2"
wrap="balance"
variant="display-strong-xs">
{title}
</Heading>
</Flex>
)}
{(avatars?.length > 0 || description?.trim() || content?.trim()) && (
<Flex
flex={7} direction="column"
gap="s">
{avatars?.length > 0 && (
<AvatarGroup
avatars={avatars}
size="m"
reverseOrder/>
)}
{description?.trim() && (
<Text
wrap="balance"
variant="body-default-s"
onBackground="neutral-weak">
{description}
</Text>
)}
{content?.trim() && (
<SmartLink
suffixIcon="chevronRight"
style={{margin: '0', width: 'fit-content'}}
href={href}>
<Text
variant="body-default-s">
Read case study
</Text>
</SmartLink>
)}
</Flex>
)}
</Flex>
</Flex>
);
};

View File

@@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { routes, protectedRoutes } from '@/app/resources';
import { Flex, Spinner, Input, Button, Heading } from '@/once-ui/components';
interface RouteGuardProps {
children: React.ReactNode;
}
const RouteGuard: React.FC<RouteGuardProps> = ({ children }) => {
const pathname = usePathname();
const [isRouteEnabled, setIsRouteEnabled] = useState(false);
const [isPasswordRequired, setIsPasswordRequired] = useState(false);
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true);
useEffect(() => {
const performChecks = async () => {
setLoading(true);
setIsRouteEnabled(false);
setIsPasswordRequired(false);
setIsAuthenticated(false);
const checkRouteEnabled = () => {
if (!pathname) return false;
if (pathname in routes) {
return routes[pathname as keyof typeof routes];
}
const dynamicRoutes = ['/blog', '/work'] as const;
for (const route of dynamicRoutes) {
if (pathname?.startsWith(route) && routes[route]) {
return true;
}
}
return false;
};
const routeEnabled = checkRouteEnabled();
setIsRouteEnabled(routeEnabled);
if (protectedRoutes[pathname as keyof typeof protectedRoutes]) {
setIsPasswordRequired(true);
const response = await fetch('/api/check-auth');
if (response.ok) {
setIsAuthenticated(true);
}
}
setLoading(false);
};
performChecks();
}, [pathname]);
const handlePasswordSubmit = async () => {
const response = await fetch('/api/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (response.ok) {
setIsAuthenticated(true);
setError(undefined);
} else {
setError('Incorrect password');
}
};
if (loading) {
return (
<Flex fillWidth paddingY="128" justifyContent="center">
<Spinner />
</Flex>
);
}
if (!isRouteEnabled) {
return (
<Flex fillWidth paddingY="128" justifyContent="center">
<Spinner />
</Flex>
);
}
if (isPasswordRequired && !isAuthenticated) {
return (
<Flex
fillWidth paddingY="128" maxWidth={24} gap="24"
justifyContent="center" direction="column" alignItems="center">
<Heading align="center" wrap="balance">
This page is password protected
</Heading>
<Input
id="password"
type="password"
label="Enter password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(undefined);
}}
error={error}/>
<Button onClick={handlePasswordSubmit} size="l">
Submit
</Button>
</Flex>
);
}
return <>{children}</>;
};
export { RouteGuard };

View File

@@ -0,0 +1,6 @@
export { Header } from '@/app/components/Header'
export { Footer } from '@/app/components/Footer'
export { Mailchimp } from '@/app/components/Mailchimp'
export { ProjectCard } from '@/app/components/ProjectCard'
export { HeadingLink } from '@/app/components/HeadingLink'
export { RouteGuard } from '@/app/components/RouteGuard'

148
src/app/components/mdx.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { MDXRemote, MDXRemoteProps } from 'next-mdx-remote/rsc';
import React, { ReactNode } from 'react';
import { SmartImage, SmartLink, Text } from '@/once-ui/components';
import { HeadingLink } from '@/app/components';
import { TextProps } from '@/once-ui/interfaces';
import { SmartImageProps } from '@/once-ui/components/SmartImage';
type TableProps = {
data: {
headers: string[];
rows: string[][];
};
};
function Table({ data }: TableProps) {
const headers = data.headers.map((header, index) => (
<th key={index}>{header}</th>
));
const rows = data.rows.map((row, index) => (
<tr key={index}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
));
return (
<table>
<thead>
<tr>{headers}</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
type CustomLinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
children: ReactNode;
};
function CustomLink({ href, children, ...props }: CustomLinkProps) {
if (href.startsWith('/')) {
return (
<SmartLink href={href} {...props}>
{children}
</SmartLink>
);
}
if (href.startsWith('#')) {
return <a href={href} {...props}>{children}</a>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
);
}
function createImage({ alt, src, ...props }: SmartImageProps & { src: string }) {
if (!src) {
console.error("SmartImage requires a valid 'src' property.");
return null;
}
return (
<SmartImage
className="my-20"
enlarge
radius="m"
aspectRatio="16 / 9"
alt={alt}
src={src}
{...props}/>
)
}
function slugify(str: string): string {
return str
.toString()
.toLowerCase()
.trim() // Remove whitespace from both ends of a string
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/&/g, '-and-') // Replace & with 'and'
.replace(/[^\w\-]+/g, '') // Remove all non-word characters except for -
.replace(/\-\-+/g, '-') // Replace multiple - with single -
}
function createHeading(level: 1 | 2 | 3 | 4 | 5 | 6) {
const CustomHeading = ({ children, ...props }: TextProps) => {
const slug = slugify(children as string);
return (
<HeadingLink
style={{marginTop: 'var(--static-space-24)', marginBottom: 'var(--static-space-12)'}}
level={level}
id={slug}
{...props}>
{children}
</HeadingLink>
);
};
CustomHeading.displayName = `Heading${level}`;
return CustomHeading;
}
function createParagraph({ children }: TextProps) {
return (
<Text style={{lineHeight: '150%'}}
variant="body-default-m"
onBackground="neutral-medium"
marginTop="8"
marginBottom="12">
{children}
</Text>
);
};
const components = {
p: createParagraph as any,
h1: createHeading(1) as any,
h2: createHeading(2) as any,
h3: createHeading(3) as any,
h4: createHeading(4) as any,
h5: createHeading(5) as any,
h6: createHeading(6) as any,
img: createImage as any,
a: CustomLink as any,
Table,
};
type CustomMDXProps = MDXRemoteProps & {
components?: typeof components;
};
export function CustomMDX(props: CustomMDXProps) {
return (
<MDXRemote
{...props}
components={{ ...components, ...(props.components || {}) }}
/>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -0,0 +1,14 @@
.masonryGrid {
display: flex;
margin-left: calc(-1 * var(--static-space-16));
width: 100%;
}
.masonryGridColumn {
padding-left: var(--static-space-16);
background-clip: padding-box;
}
.gridItem {
margin-bottom: var(--static-space-16);
}

View File

@@ -0,0 +1,33 @@
"use client";
import Masonry from 'react-masonry-css';
import { SmartImage } from "@/once-ui/components";
import { gallery } from "@/app/resources";
import styles from "@/app/gallery/Gallery.module.scss";
export default function MasonryGrid() {
const breakpointColumnsObj = {
default: 4,
1440: 3,
1024: 2,
560: 1
};
return (
<Masonry
breakpointCols={breakpointColumnsObj}
className={styles.masonryGrid}
columnClassName={styles.masonryGridColumn}>
{gallery.images.map((image, index) => (
<SmartImage
key={index}
radius="m"
aspectRatio={image.orientation === "horizontal" ? "16 / 9" : "9 / 16"}
src={image.src}
alt={image.alt}
className={styles.gridItem}
/>
))}
</Masonry>
);
}

66
src/app/gallery/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { Flex } from "@/once-ui/components";
import MasonryGrid from "./components/MasonryGrid";
import { baseURL, gallery, person } from "../resources";
export function generateMetadata() {
const title = gallery.title;
const description = gallery.description;
const ogImage = `https://${baseURL}/og?title=${encodeURIComponent(title)}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
url: `https://${baseURL}/gallery`,
images: [
{
url: ogImage,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
export default function Gallery() {
return (
<Flex fillWidth>
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'ImageGallery',
name: gallery.title,
description: gallery.description,
url: `https://${baseURL}/gallery`,
image: gallery.images.map((image) => ({
'@type': 'ImageObject',
url: `${baseURL}${image.src}`,
description: image.alt,
})),
author: {
'@type': 'Person',
name: person.name,
image: {
'@type': 'ImageObject',
url: `${baseURL}${person.avatar}`,
},
},
}),
}}
/>
<MasonryGrid/>
</Flex>
);
}

114
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,114 @@
import "@/once-ui/styles/index.scss";
import "@/once-ui/tokens/index.scss";
import classNames from 'classnames';
import { Flex, Background } from '@/once-ui/components'
import { Footer, Header, RouteGuard } from "@/app/components";
import { baseURL, effects, home, person, style } from '@/app/resources'
import { Inter } from 'next/font/google'
import { Source_Code_Pro } from 'next/font/google';
import { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL('https://' + baseURL),
title: home.title,
description: home.description,
openGraph: {
title: `${person.firstName}'s Portfolio`,
description: 'Portfolio website showcasing my work.',
url: baseURL,
siteName: `${person.firstName}'s Portfolio`,
locale: 'en_US',
type: 'website',
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
const primary = Inter({
variable: '--font-primary',
subsets: ['latin'],
display: 'swap',
})
type FontConfig = {
variable: string;
};
/*
Replace with code for secondary and tertiary fonts
from https://once-ui.com/customize
*/
const secondary: FontConfig | undefined = undefined;
const tertiary: FontConfig | undefined = undefined;
/*
*/
const code = Source_Code_Pro({
variable: '--font-code',
subsets: ['latin'],
display: 'swap',
});
interface RootLayoutProps {
children: React.ReactNode;
}
export default function RootLayout({ children } : RootLayoutProps) {
return (
<Flex
as="html" lang="en"
background="page"
data-neutral={style.neutral} data-brand={style.brand} data-accent={style.accent}
data-solid={style.solid} data-solid-style={style.solidStyle}
data-theme={style.theme}
data-border={style.border}
data-surface={style.surface}
data-transition={style.transition}
className={classNames(
primary.variable,
secondary ? secondary.variable : '',
tertiary ? tertiary.variable : '',
code.variable)}>
<Flex style={{minHeight: '100vh'}}
as="body"
fillWidth margin="0" padding="0"
direction="column">
<Background
gradient={effects.gradient}
dots={effects.dots}
lines={effects.lines}/>
<Flex
fillWidth
minHeight="16">
</Flex>
<Header/>
<Flex
zIndex={0}
fillWidth paddingY="l" paddingX="l"
justifyContent="center" flex={1}>
<Flex
justifyContent="center"
fillWidth minHeight="0">
<RouteGuard>
{children}
</RouteGuard>
</Flex>
</Flex>
<Footer/>
</Flex>
</Flex>
);
}

24
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Flex, Heading, Text } from "@/once-ui/components";
export default function NotFound() {
return (
<Flex
as="section"
direction="column" alignItems="center">
<Text
marginBottom="s"
variant="display-strong-xl">
404
</Text>
<Heading
marginBottom="l"
variant="display-strong-xs">
Page Not Found
</Heading>
<Text
onBackground="neutral-weak">
The page you are looking for does not exist.
</Text>
</Flex>
)
}

100
src/app/og/route.tsx Normal file
View File

@@ -0,0 +1,100 @@
import { ImageResponse } from 'next/og'
import { baseURL } from '@/app/resources';
import { person } from '@/app/resources';
export const runtime = 'edge';
export async function GET(request: Request) {
let url = new URL(request.url)
let title = url.searchParams.get('title') || 'Portfolio'
const font = fetch(
new URL('../../../public/fonts/Inter.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
const fontData = await font;
return new ImageResponse(
(
<div
style={{
display: 'flex',
width: '100%',
height: '100%',
padding: '8rem',
background: '#151515',
}}>
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '4rem',
fontFamily: 'Inter',
fontStyle: 'normal',
color: 'white',
}}>
<span
style={{
fontSize: '8rem',
lineHeight: '8rem',
letterSpacing: '-0.05em',
whiteSpace: 'pre-wrap',
textWrap: 'balance',
}}>
{title}
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '5rem'
}}>
<img src={'https://' + baseURL + person.avatar}
style={{
width: '12rem',
height: '12rem',
objectFit: 'cover',
borderRadius: '100%',
}}/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.75rem'
}}>
<span
style={{
fontSize: '4.5rem',
lineHeight: '4.5rem',
whiteSpace: 'pre-wrap',
textWrap: 'balance',
}}>
{person.name}
</span>
<span
style={{
fontSize: '2.5rem',
lineHeight: '2.5rem',
whiteSpace: 'pre-wrap',
textWrap: 'balance',
opacity: '0.6'
}}>
{person.role}
</span>
</div>
</div>
</div>
</div>
),
{
width: 1920,
height: 1080,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
},
],
}
)
}

126
src/app/page.tsx Normal file
View File

@@ -0,0 +1,126 @@
import React from 'react';
import { Heading, Flex, Text, Button, Avatar, RevealFx } from '@/once-ui/components';
import { Projects } from '@/app/work/components/Projects';
import { about, baseURL, home, newsletter, person, routes } from '@/app/resources'
import { Mailchimp } from '@/app/components';
import { Posts } from '@/app/blog/components/Posts';
export function generateMetadata() {
const title = home.title;
const description = home.description;
const ogImage = `https://${baseURL}/og?title=${encodeURIComponent(title)}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
url: `https://${baseURL}`,
images: [
{
url: ogImage,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
export default function Home() {
return (
<Flex
maxWidth="m" fillWidth gap="xl"
direction="column" alignItems="center">
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: home.title,
description: home.description,
url: `https://${baseURL}`,
image: `${baseURL}/og?title=${encodeURIComponent(home.title)}`,
publisher: {
'@type': 'Person',
name: person.name,
image: {
'@type': 'ImageObject',
url: `${baseURL}${person.avatar}`,
},
},
}),
}}
/>
<Flex
fillWidth
direction="column"
paddingY="l" gap="m">
<Flex
direction="column"
fillWidth maxWidth="s" gap="m">
<RevealFx translateY="4">
<Heading
wrap="balance"
variant="display-strong-l">
{home.headline}
</Heading>
</RevealFx>
<RevealFx translateY="8" delay={0.2}>
<Text
wrap="balance"
onBackground="neutral-weak"
variant="body-default-l">
{home.subline}
</Text>
</RevealFx>
<RevealFx translateY="12" delay={0.4}>
<Button
data-border="rounded"
href="/about"
variant="tertiary"
suffixIcon="chevronRight"
size="m">
<Flex
gap="8"
alignItems="center">
{about.avatar.display && (
<Avatar
style={{marginLeft: '-0.75rem', marginRight: '0.25rem'}}
src={person.avatar}
size="m"/>
)}
About me
</Flex>
</Button>
</RevealFx>
</Flex>
</Flex>
<RevealFx translateY="16" delay={0.6}>
<Projects range={[1,1]}/>
</RevealFx>
{routes['/blog'] && (
<Flex fillWidth paddingX="20">
<Posts range={[1,2]} columns="2"/>
</Flex>
)}
<Projects range={[2]}/>
{ newsletter.display &&
<Mailchimp/>
}
</Flex>
);
}

View File

@@ -0,0 +1,49 @@
const baseURL = 'nextjs-portfolio.up.railway.app'
const routes = {
'/': true,
'/about': true,
'/work': false,
'/blog': true,
'/gallery': true,
}
// Enable password protection on selected routes
// Set password in pages/api/authenticate.ts
const protectedRoutes = {
}
const effects = {
gradient: false,
dots: false,
lines: true,
}
const style = {
theme: 'dark', // dark | light
neutral: 'slate', // sand | gray | slate
brand: 'indigo', // blue | indigo | violet | magenta | pink | red | orange | yellow | moss | green | emerald | aqua | cyan
accent: 'blue', // blue | indigo | violet | magenta | pink | red | orange | yellow | moss | green | emerald | aqua | cyan
solid: 'color', // color | contrast
solidStyle: 'flat', // flat | plastic
border: 'rounded', // rounded | playful | conservative
surface: 'filled', // filled | translucent
transition: 'all' // all | micro | macro
}
const display = {
location: true,
time: true
}
const mailchimp = {
action: 'https://url/subscribe/post?parameters',
effects: {
gradient: true,
dots: false,
lines: false,
}
}
export { routes, protectedRoutes, effects, style, display, mailchimp, baseURL };

View File

@@ -0,0 +1,49 @@
const baseURL = 'nextjs-portfolio.up.railway.app'
const routes = {
'/': true,
'/about': true,
'/work': true,
'/blog': true,
'/gallery': false,
}
// Enable password protection on selected routes
// Set password in pages/api/authenticate.ts
const protectedRoutes = {
}
const effects = {
gradient: false,
dots: false,
lines: false,
}
const style = {
theme: 'light', // dark | light
neutral: 'gray', // sand | gray | slate
brand: 'blue', // blue | indigo | violet | magenta | pink | red | orange | yellow | moss | green | emerald | aqua | cyan
accent: 'blue', // blue | indigo | violet | magenta | pink | red | orange | yellow | moss | green | emerald | aqua | cyan
solid: 'contrast', // color | contrast
solidStyle: 'flat', // flat | plastic
border: 'conservative', // rounded | playful | conservative
surface: 'filled', // filled | translucent
transition: 'all' // all | micro | macro
}
const display = {
location: false,
time: false
}
const mailchimp = {
action: 'https://url/subscribe/post?parameters',
effects: {
gradient: false,
dots: false,
lines: true,
}
}
export { routes, protectedRoutes, effects, style, display, mailchimp, baseURL };

View File

@@ -0,0 +1,51 @@
const baseURL = 'nextjs-portfolio.up.railway.app'
const routes = {
'/': true,
'/about': true,
'/work': true,
'/blog': true,
'/gallery': true,
}
// Enable password protection on selected routes
// Set password in pages/api/authenticate.ts
const protectedRoutes = {
'/work/automate-design-handovers-with-a-figma-to-code-pipeline': true
}
const effects = {
gradient: false,
dots: false,
lines: false,
shootingStars: false,
gridpatterns: true,
}
const style = {
theme: 'dark', // dark | light
neutral: 'gray', // sand | gray | slate
brand: 'orange', // blue | indigo | violet | magenta | pink | red | orange | yellow | moss | green | emerald | aqua | cyan
accent: 'yellow', // blue | indigo | violet | magenta | pink | red | orange | yellow | moss | green | emerald | aqua | cyan
solid: 'contrast', // color | contrast
solidStyle: 'flat', // flat | plastic
border: 'playful', // rounded | playful | conservative
surface: 'translucent', // filled | translucent
transition: 'all' // all | micro | macro
}
const display = {
location: true,
time: true
}
const mailchimp = {
action: 'https://url/subscribe/post?parameters',
effects: {
gradient: false,
dots: false,
lines: true,
}
}
export { routes, protectedRoutes, effects, style, display, mailchimp, baseURL };

View File

@@ -0,0 +1,258 @@
import { InlineCode } from "@/once-ui/components";
import Link from 'next/link'
const person = {
firstName: 'Selene',
lastName: 'Yu',
get name() {
return `${this.firstName} ${this.lastName}`;
},
role: 'Design Engineer',
avatar: '/images/avatar.jpg',
location: 'Asia/Jakarta', // Expecting the IANA time zone identifier, e.g., 'Europe/Vienna'
languages: ['English', 'Bahasa'] // optional: Leave the array empty if you don't want to display languages
}
const newsletter = {
title: <>Subscribe to {person.firstName}'s Newsletter</>,
description: <>I occasionally write about design, technology, and share thoughts on the intersection of creativity and engineering.</>
}
const social = [
// Links are automatically displayed.
// Import new icons in /once-ui/icons.ts
{
name: 'GitHub',
icon: 'github',
link: 'https://github.com/once-ui-system/nextjs-starter',
},
{
name: 'LinkedIn',
icon: 'linkedin',
link: 'https://www.linkedin.com/company/once-ui/',
},
{
name: 'X',
icon: 'x',
link: '',
},
{
name: 'Email',
icon: 'email',
link: 'mailto:example@gmail.com',
},
]
const home = {
label: 'Home',
title: `${person.name}'s Portfolio`,
description: `Portfolio website showcasing my work as a ${person.role}`,
headline: <>Design engineer and builder</>,
subline: <>I'm Selene, a design engineer at <InlineCode>FLY</InlineCode>, where I craft intuitive user experiences. After hours, I build my own projects.</>
}
const about = {
label: 'About',
title: 'About me',
description: `Meet ${person.name}, ${person.role} from ${person.location}`,
tableOfContent: {
display: false,
subItems: false
},
avatar: {
display: false
},
calendar: {
display: false,
link: 'https://cal.com'
},
intro: {
display: true,
title: 'Introduction',
description:
<>
<p>Selene is a Jakarta-based design engineer with a passion for transforming complex challenges into simple, elegant design solutions. Her work spans digital interfaces, interactive experiences, and the convergence of design and technology.</p>
<p>My work spans a diverse range of disciplines, from crafting <Link href="/work">intuitive digital interfaces</Link> to designing immersive interactive experiences. Im particularly interested in the intersection of design and engineering, where aesthetics meet functionality. I believe that the best solutions arise from a balance of creativity and technical rigor, and I enjoy the challenge of finding that balance in every project I undertake.</p>
</>
},
work: {
display: false, // set to false to hide this section
title: 'Work Experience',
experiences: [
{
company: 'FLY',
timeframe: '2022 - Present',
role: 'Senior Design Engineer',
achievements: [
<>Redesigned the UI/UX for the FLY platform, resulting in a 20% increase in user engagement and 30% faster load times.</>,
<>Spearheaded the integration of AI tools into design workflows, enabling designers to iterate 50% faster.</>
],
images: [ // optional: leave the array empty if you don't want to display images
{
src: '/images/projects/project-01/cover-01.jpg',
alt: 'Once UI Project',
width: 16,
height: 9
}
]
},
{
company: 'Creativ3',
timeframe: '2018 - 2022',
role: 'Lead Designer',
achievements: [
<>Developed a design system that unified the brand across multiple platforms, improving design consistency by 40%.</>,
<>Led a cross-functional team to launch a new product line, contributing to a 15% increase in overall company revenue.</>
],
images: [ ]
}
]
},
studies: {
display: false, // set to false to hide this section
title: 'Studies',
institutions: [
{
name: 'University of Jakarta',
description: <>Studied software engineering.</>,
},
{
name: 'Build the Future',
description: <>Studied online marketing and personal branding.</>,
}
]
},
technical: {
display: false, // set to false to hide this section
title: 'Technical skills',
skills: [
{
title: 'Figma',
description: <>Able to prototype in Figma with Once UI with unnatural speed.</>,
images: [
{
src: '/images/projects/project-01/cover-02.jpg',
alt: 'Project image',
width: 16,
height: 9
},
{
src: '/images/projects/project-01/cover-03.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
},
{
title: 'Next.js',
description: <>Building next gen apps with Next.js + Once UI + Supabase.</>,
images: [
{
src: '/images/projects/project-01/cover-04.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
}
]
}
}
const blog = {
label: 'Blog',
title: 'Writing about design and tech...',
description: `Read what ${person.name} has been up to recently`
// Create new blog posts by adding a new .mdx file to app/blog/posts
// All posts will be listed on the /blog route
}
const work = {
label: 'Work',
title: 'My projects',
description: `Design and dev projects by ${person.name}`
// Create new project pages by adding a new .mdx file to app/blog/posts
// All projects will be listed on the /home and /work routes
}
const gallery = {
label: 'Gallery',
title: 'My photo gallery',
description: `A photo collection by ${person.name}`,
// Images from https://pexels.com
images: [
{
src: '/images/gallery/img-01.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-02.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-03.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-04.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-05.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-06.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-07.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-08.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-09.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-10.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-11.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-12.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-13.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-14.jpg',
alt: 'image',
orientation: 'horizontal'
},
]
}
export { person, social, newsletter, home, about, blog, work, gallery };

View File

@@ -0,0 +1,258 @@
import { InlineCode } from "@/once-ui/components";
import Link from 'next/link'
const person = {
firstName: 'Selene',
lastName: 'Yu',
get name() {
return `${this.firstName} ${this.lastName}`;
},
role: 'Design Engineer',
avatar: '/images/avatar.jpg',
location: 'Asia/Jakarta', // Expecting the IANA time zone identifier, e.g., 'Europe/Vienna'
languages: ['English', 'Bahasa'] // optional: Leave the array empty if you don't want to display languages
}
const newsletter = {
title: <>Subscribe to {person.firstName}'s Newsletter</>,
description: <>I occasionally write about design, technology, and share thoughts on the intersection of creativity and engineering.</>
}
const social = [
// Links are automatically displayed.
// Import new icons in /once-ui/icons.ts
{
name: 'GitHub',
icon: 'github',
link: 'https://github.com/once-ui-system/nextjs-starter',
},
{
name: 'LinkedIn',
icon: 'linkedin',
link: 'https://www.linkedin.com/company/once-ui/',
},
{
name: 'X',
icon: 'x',
link: '',
},
{
name: 'Email',
icon: 'email',
link: 'mailto:example@gmail.com',
},
]
const home = {
label: 'Home',
title: `${person.name}'s Portfolio`,
description: `Portfolio website showcasing my work as a ${person.role}`,
headline: <>Design engineer and builder</>,
subline: <>I'm Selene, a design engineer at <InlineCode>FLY</InlineCode>, where I craft intuitive user experiences. After hours, I build my own projects.</>
}
const about = {
label: 'About',
title: 'About me',
description: `Meet ${person.name}, ${person.role} from ${person.location}`,
tableOfContent: {
display: false,
subItems: false
},
avatar: {
display: false
},
calendar: {
display: false,
link: 'https://cal.com'
},
intro: {
display: true,
title: 'Introduction',
description:
<>
<p>Selene is a Jakarta-based design engineer with a passion for transforming complex challenges into simple, elegant design solutions. Her work spans digital interfaces, interactive experiences, and the convergence of design and technology.</p>
<p>My work spans a diverse range of disciplines, from crafting <Link href="/work">intuitive digital interfaces</Link> to designing immersive interactive experiences. Im particularly interested in the intersection of design and engineering, where aesthetics meet functionality. I believe that the best solutions arise from a balance of creativity and technical rigor, and I enjoy the challenge of finding that balance in every project I undertake.</p>
</>
},
work: {
display: true, // set to false to hide this section
title: 'Work Experience',
experiences: [
{
company: 'FLY',
timeframe: '2022 - Present',
role: 'Senior Design Engineer',
achievements: [
<>Redesigned the UI/UX for the FLY platform, resulting in a 20% increase in user engagement and 30% faster load times.</>,
<>Spearheaded the integration of AI tools into design workflows, enabling designers to iterate 50% faster.</>
],
images: [ // optional: leave the array empty if you don't want to display images
{
src: '/images/projects/project-01/cover-01.jpg',
alt: 'Once UI Project',
width: 16,
height: 9
}
]
},
{
company: 'Creativ3',
timeframe: '2018 - 2022',
role: 'Lead Designer',
achievements: [
<>Developed a design system that unified the brand across multiple platforms, improving design consistency by 40%.</>,
<>Led a cross-functional team to launch a new product line, contributing to a 15% increase in overall company revenue.</>
],
images: [ ]
}
]
},
studies: {
display: true, // set to false to hide this section
title: 'Studies',
institutions: [
{
name: 'University of Jakarta',
description: <>Studied software engineering.</>,
},
{
name: 'Build the Future',
description: <>Studied online marketing and personal branding.</>,
}
]
},
technical: {
display: false, // set to false to hide this section
title: 'Technical skills',
skills: [
{
title: 'Figma',
description: <>Able to prototype in Figma with Once UI with unnatural speed.</>,
images: [
{
src: '/images/projects/project-01/cover-02.jpg',
alt: 'Project image',
width: 16,
height: 9
},
{
src: '/images/projects/project-01/cover-03.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
},
{
title: 'Next.js',
description: <>Building next gen apps with Next.js + Once UI + Supabase.</>,
images: [
{
src: '/images/projects/project-01/cover-04.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
}
]
}
}
const blog = {
label: 'Blog',
title: 'Writing about design and tech...',
description: `Read what ${person.name} has been up to recently`
// Create new blog posts by adding a new .mdx file to app/blog/posts
// All posts will be listed on the /blog route
}
const work = {
label: 'Work',
title: 'My projects',
description: `Design and dev projects by ${person.name}`
// Create new project pages by adding a new .mdx file to app/blog/posts
// All projects will be listed on the /home and /work routes
}
const gallery = {
label: 'Gallery',
title: 'My photo gallery',
description: `A photo collection by ${person.name}`,
// Images from https://pexels.com
images: [
{
src: '/images/gallery/img-01.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-02.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-03.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-04.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-05.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-06.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-07.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-08.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-09.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-10.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-11.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-12.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-13.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-14.jpg',
alt: 'image',
orientation: 'horizontal'
},
]
}
export { person, social, newsletter, home, about, blog, work, gallery };

View File

@@ -0,0 +1,300 @@
import { InlineCode } from "@/once-ui/components";
const person = {
firstName: 'Casper',
lastName: 'Olsthoorn',
get name() {
return `${this.firstName} ${this.lastName}`;
},
role: 'IT System Administrator',
avatar: '/images/avatar.jpg',
location: 'Europe/Amsterdam', // Expecting the IANA time zone identifier, e.g., 'Europe/Vienna'
languages: ['Nederlands', 'English'] // optional: Leave the array empty if you don't want to display languages
}
const newsletter = {
display: false,
title: <>Subscribe to {person.firstName}'s Newsletter</>,
description: <>I occasionally write about design, technology, and share thoughts on the intersection of creativity and engineering.</>
}
const social = [
// Links are automatically displayed.
// Import new icons in /once-ui/icons.ts
{
name: 'GitHub',
icon: 'github',
link: '',
},
{
name: 'LinkedIn',
icon: 'linkedin',
link: 'https://www.linkedin.com/in/casper-olsthoorn/',
},
{
name: 'X',
icon: 'x',
link: '',
},
{
name: 'Email',
icon: 'email',
link: 'mailto:Casper.olsthoorn@gmail.com',
},
]
const home = {
label: 'Home',
title: `${person.name}'s Portfolio`,
description: `Portfolio website showcasing my work as a ${person.role}`,
headline: <>Design engineer and builder</>,
subline: <>I'm Selene, a design engineer at <InlineCode>FLY</InlineCode>, where I craft intuitive<br/> user experiences. After hours, I build my own projects.</>
}
const about = {
label: 'About',
title: 'About me',
description: `Meet ${person.name}, ${person.role} from ${person.location}`,
tableOfContent: {
display: true,
subItems: true
},
avatar: {
display: true
},
calendar: {
display: false,
link: 'https://cal.com'
},
intro: {
display: true,
title: 'Introduction',
description: <>Casper is a system administrator with a passion for IT. Based in the Netherlands, he manages servers, networks, and applications with precision and care. His work blends technical expertise with an ambitious vision for the future, focusing on practical solutions and innovative growth.</>
},
work: {
display: true, // set to false to hide this section
title: 'Work Experience',
experiences: [
{
company: 'vBoxx',
timeframe: 'aug. 2024 - Present',
role: 'Stagiar | Systeem Beheerder',
achievements: [
<>Redesigned the UI/UX for the FLY platform, resulting in a 20% increase in user engagement and 30% faster load times.</>,
],
images: [ // optional: leave the array empty if you don't want to display images
{
src: '/images/projects/project-01/cover-01.jpg',
alt: 'Once UI Project',
width: 16,
height: 9
}
]
},
{
company: 'Fleetport',
timeframe: 'jul. 2024 - Present',
role: 'Fleetdriver',
achievements: [
<>During my time at Fleetport, I gained valuable experience in customer interaction by ensuring vehicles were delivered to clients with complete satisfaction.</>,
],
images: [ // optional: leave the array empty if you don't want to display images
]
},
{
company: 'Jumbo',
timeframe: 'okt. 2021 - aug 2022',
role: 'Vakkenvuller',
achievements: [
<>Redesigned the UI/UX for the FLY platform, resulting in a 20% increase in user engagement and 30% faster load times.</>,
],
images: [ // optional: leave the array empty if you don't want to display images
]
},
]
},
studies: {
display: true, // set to false to hide this section
title: 'Studies',
institutions: [
{
name: 'Grafisch Lyceum Rotterdam MBO',
description: <>Expert IT Systems & Devices</>,
},
{
name: 'Grafisch Lyceum Rotterdam VMBO',
description: <>Media, Vormgeven & ICT</>,
}
]
},
certificates: {
display: true, // set to false to hide this section
title: 'Certififcates',
certs: [
{
title: 'Velocity',
description: <>Able to prototype in Figma with Once UI with unnatural speed.</>,
images: [
{
src: '/images/projects/project-01/cover-02.jpg',
alt: 'Project image',
width: 16,
height: 9
},
{
src: '/images/projects/project-01/cover-03.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
},
{
title: 'Dante',
description: <>Building next gen apps with Next.js + Once UI + Supabase.</>,
images: [
{
src: '/images/projects/project-01/cover-04.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
}
]
},
technical: {
display: true, // set to false to hide this section
title: 'Technical skills',
skills: [
{
title: 'Sophos Firewall',
description: <>Able to setup Sophos Firewall With advanced rules for every different need</>,
images: [
{
src: '/images/projects/project-01/cover-02.jpg',
alt: 'Project image',
width: 16,
height: 9
},
{
src: '/images/projects/project-01/cover-03.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
},
{
title: 'Proxmox',
description: <>Able to setup proxmox clusters with ceph for redudencies and Hight Avelibility </>,
images: [
{
src: '/images/projects/project-01/cover-04.jpg',
alt: 'Project image',
width: 16,
height: 9
},
]
}
]
},
}
const blog = {
label: 'Blog',
title: 'Writing about design and tech...',
description: `Read what ${person.name} has been up to recently`
// Create new blog posts by adding a new .mdx file to app/blog/posts
// All posts will be listed on the /blog route
}
const work = {
label: 'Work',
title: 'My projects',
description: `Design and dev projects by ${person.name}`
// Create new project pages by adding a new .mdx file to app/blog/posts
// All projects will be listed on the /home and /work routes
}
const gallery = {
label: 'Gallery',
title: 'My photo gallery',
description: `A photo collection by ${person.name}`,
// Images from https://pexels.com
images: [
{
src: '/images/gallery/img-01.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-02.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-03.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-04.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-05.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-06.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-07.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-08.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-09.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-10.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-11.jpg',
alt: 'image',
orientation: 'vertical'
},
{
src: '/images/gallery/img-12.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-13.jpg',
alt: 'image',
orientation: 'horizontal'
},
{
src: '/images/gallery/img-14.jpg',
alt: 'image',
orientation: 'horizontal'
},
]
}
export { person, social, newsletter, home, about, blog, work, gallery };

View File

@@ -0,0 +1,3 @@
// import a pre-defined template for config and content options
export { routes, protectedRoutes, effects, style, display, mailchimp, baseURL } from '@/app/resources/config'
export { person, social, newsletter, home, about, blog, work, gallery } from '@/app/resources/content'

12
src/app/robots.ts Normal file
View File

@@ -0,0 +1,12 @@
import { baseURL } from '@/app/resources'
export default function robots() {
return {
rules: [
{
userAgent: '*',
},
],
sitemap: `${baseURL}/sitemap.xml`,
}
}

21
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,21 @@
import { getPosts } from '@/app/utils'
import { baseURL } from '@/app/resources'
export default async function sitemap() {
let blogs = getPosts(['src', 'app', 'blog', 'posts']).map((post) => ({
url: `${baseURL}/blog/${post.slug}`,
lastModified: post.metadata.publishedAt,
}))
let works = getPosts(['src', 'app', 'work', 'projects']).map((post) => ({
url: `${baseURL}/work/${post.slug}`,
lastModified: post.metadata.publishedAt,
}))
let routes = ['', '/blog', '/work'].map((route) => ({
url: `${baseURL}${route}`,
lastModified: new Date().toISOString().split('T')[0],
}))
return [...routes, ...blogs, ...works]
}

102
src/app/utils.ts Normal file
View File

@@ -0,0 +1,102 @@
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
type Team = {
name: string;
role: string;
avatar: string;
linkedIn: string;
};
type Metadata = {
title: string;
publishedAt: string;
summary: string;
image?: string;
images: string[];
team: Team[];
};
function getMDXFiles(dir: string) {
if (!fs.existsSync(dir)) {
throw new Error(`Directory not found: ${dir}`);
}
return fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx');
}
function readMDXFile(filePath: string) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const rawContent = fs.readFileSync(filePath, 'utf-8');
const { data, content } = matter(rawContent);
const metadata: Metadata = {
title: data.title || '',
publishedAt: data.publishedAt,
summary: data.summary || '',
images: data.images || [],
team: data.team || [],
};
return { metadata, content };
}
function getMDXData(dir: string) {
const mdxFiles = getMDXFiles(dir);
return mdxFiles.map((file) => {
const { metadata, content } = readMDXFile(path.join(dir, file));
const slug = path.basename(file, path.extname(file));
return {
metadata,
slug,
content,
};
});
}
export function getPosts(customPath = ['', '', '', '']) {
const postsDir = path.join(process.cwd(), ...customPath);
return getMDXData(postsDir);
}
export function formatDate(date: string, includeRelative = false) {
const currentDate = new Date();
if (!date.includes('T')) {
date = `${date}T00:00:00`;
}
const targetDate = new Date(date);
const yearsAgo = currentDate.getFullYear() - targetDate.getFullYear();
const monthsAgo = currentDate.getMonth() - targetDate.getMonth();
const daysAgo = currentDate.getDate() - targetDate.getDate();
let formattedDate = '';
if (yearsAgo > 0) {
formattedDate = `${yearsAgo}y ago`;
} else if (monthsAgo > 0) {
formattedDate = `${monthsAgo}mo ago`;
} else if (daysAgo > 0) {
formattedDate = `${daysAgo}d ago`;
} else {
formattedDate = 'Today';
}
const fullDate = targetDate.toLocaleString('en-us', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
if (!includeRelative) {
return fullDate;
}
return `${fullDate} (${formattedDate})`;
}

View File

@@ -0,0 +1,149 @@
import { notFound } from 'next/navigation'
import { CustomMDX } from '@/app/components/mdx'
import { formatDate, getPosts } from '@/app/utils'
import { AvatarGroup, Button, Flex, Heading, SmartImage, Text } from '@/once-ui/components'
import { baseURL, person } from '@/app/resources';
interface WorkParams {
params: {
slug: string;
};
}
export async function generateStaticParams() {
let posts = getPosts(['src', 'app', 'work', 'projects']);
return posts.map((post) => ({
slug: post.slug,
}))
}
export function generateMetadata({ params }: WorkParams) {
let post = getPosts(['src', 'app', 'work', 'projects']).find((post) => post.slug === params.slug)
if (!post) {
return
}
let {
title,
publishedAt: publishedTime,
summary: description,
images,
image,
team,
} = post.metadata
let ogImage = image
? `https://${baseURL}${image}`
: `https://${baseURL}/og?title=${title}`;
return {
title,
description,
images,
team,
openGraph: {
title,
description,
type: 'article',
publishedTime,
url: `https://${baseURL}/work/${post.slug}`,
images: [
{
url: ogImage,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
}
}
export default function Project({ params }: WorkParams) {
let post = getPosts(['src', 'app', 'work', 'projects']).find((post) => post.slug === params.slug)
if (!post) {
notFound()
}
const avatars = post.metadata.team?.map((person) => ({
src: person.avatar,
})) || [];
return (
<Flex as="section"
fillWidth maxWidth="m"
direction="column" alignItems="center"
gap="l">
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.metadata.title,
datePublished: post.metadata.publishedAt,
dateModified: post.metadata.publishedAt,
description: post.metadata.summary,
image: post.metadata.image
? `https://${baseURL}${post.metadata.image}`
: `https://${baseURL}/og?title=${post.metadata.title}`,
url: `https://${baseURL}/work/${post.slug}`,
author: {
'@type': 'Person',
name: person.name,
},
}),
}}
/>
<Flex
fillWidth maxWidth="xs" gap="16"
direction="column">
<Button
href="/work"
variant="tertiary"
size="s"
prefixIcon="chevronLeft">
Projects
</Button>
<Heading
variant="display-strong-s">
{post.metadata.title}
</Heading>
</Flex>
{post.metadata.images.length > 0 && (
<SmartImage
aspectRatio="16 / 9"
radius="m"
alt="image"
src={post.metadata.images[0]}/>
)}
<Flex style={{margin: 'auto'}}
as="article"
maxWidth="xs" fillWidth
direction="column">
<Flex
gap="12" marginBottom="24"
alignItems="center">
{ post.metadata.team && (
<AvatarGroup
reverseOrder
avatars={avatars}
size="m"/>
)}
<Text
variant="body-default-s"
onBackground="neutral-weak">
{formatDate(post.metadata.publishedAt)}
</Text>
</Flex>
<CustomMDX source={post.content} />
</Flex>
</Flex>
)
}

View File

@@ -0,0 +1,17 @@
.hover {
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
&:hover {
transform: translateX(var(--static-space-8));
.indicator {
transform: rotate(0);
}
}
}
.indicator {
transform: rotate(-90deg);
left: -2rem;
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
}

View File

@@ -0,0 +1,37 @@
import { getPosts } from '@/app/utils';
import { Flex } from '@/once-ui/components';
import { ProjectCard } from '@/app/components';
interface ProjectsProps {
range?: [number, number?];
}
export function Projects({ range }: ProjectsProps) {
let allProjects = getPosts(['src', 'app', 'work', 'projects']);
const sortedProjects = allProjects.sort((a, b) => {
return new Date(b.metadata.publishedAt).getTime() - new Date(a.metadata.publishedAt).getTime();
});
const displayedProjects = range
? sortedProjects.slice(range[0] - 1, range[1] ?? sortedProjects.length)
: sortedProjects;
return (
<Flex
fillWidth gap="l" marginBottom="40" paddingX="l"
direction="column">
{displayedProjects.map((post) => (
<ProjectCard
key={post.slug}
href={`/work/${post.slug}`}
images={post.metadata.images}
title={post.metadata.title}
description={post.metadata.summary}
content={post.content}
avatars={post.metadata.team?.map((member) => ({ src: member.avatar })) || []}/>
))}
</Flex>
);
}

70
src/app/work/page.tsx Normal file
View File

@@ -0,0 +1,70 @@
import { getPosts } from '@/app/utils';
import { Flex } from '@/once-ui/components';
import { Projects } from '@/app/work/components/Projects';
import { baseURL, person, work } from '../resources';
export function generateMetadata() {
const title = work.title;
const description = work.description;
const ogImage = `https://${baseURL}/og?title=${encodeURIComponent(title)}`;
return {
title,
description,
openGraph: {
title,
description,
type: 'website',
url: `https://${baseURL}/work`,
images: [
{
url: ogImage,
alt: title,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}
export default function Work() {
let allProjects = getPosts(['src', 'app', 'work', 'projects']);
return (
<Flex
fillWidth maxWidth="m"
direction="column">
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'CollectionPage',
headline: work.title,
description: work.description,
url: `https://${baseURL}/projects`,
image: `${baseURL}/og?title=Design%20Projects`,
author: {
'@type': 'Person',
name: person.name,
},
hasPart: allProjects.map(project => ({
'@type': 'CreativeWork',
headline: project.metadata.title,
description: project.metadata.summary,
url: `https://${baseURL}/projects/${project.slug}`,
image: `${baseURL}/${project.metadata.image}`,
})),
}),
}}
/>
<Projects/>
</Flex>
);
}

View File

@@ -0,0 +1,45 @@
---
title: "Building an adaptive design system for Archlight"
publishedAt: "2024-04-08"
summary: "In this project, I developed a flexible and scalable design system using Next.js for front-end development and Figma for design collaboration."
images:
- "/images/projects/project-01/cover-01.jpg"
- "/images/projects/project-01/cover-02.jpg"
- "/images/projects/project-01/cover-03.jpg"
- "/images/projects/project-01/cover-04.jpg"
team:
- name: "Selene Yu"
role: "Software Engineer"
avatar: "/images/avatar.jpg"
linkedIn: "https://www.linkedin.com/company/once-ui/"
- name: "Jane Smith"
role: "Product Manager"
avatar: "/images/projects/project-01/avatar-01.png"
linkedIn: "https://www.linkedin.com/in/janesmith"
---
## Overview
In this project, I developed a flexible and scalable design system using Next.js for front-end development and Figma for design collaboration. The goal was to create a reusable component library that not only adheres to consistent design principles but is also easily extendable for future needs. The design system was aimed at improving the overall developer experience while maintaining visual consistency across multiple projects.
## Key Features
- **Component Library**: Built a set of modular, reusable UI components using React and styled-components in Next.js, focusing on accessibility and responsiveness.
- **Theming and Customization**: Integrated a theming system that allows easy switching and customization of color palettes, typography, and layout styles using CSS variables and Figma tokens.
- **Figma Integration**: Collaborated closely with designers by setting up a shared design library in Figma. This library was synchronized with the codebase, ensuring design handoffs were seamless and that design tokens remained consistent across both platforms.
- **Documentation and Usage Guidelines**: Developed comprehensive documentation with Storybook to showcase components, usage patterns, and best practices, ensuring the design system is easy to adopt by other teams.
## Technologies Used
- **Next.js**: For fast, server-rendered React applications.
- **Figma**: For creating and managing design assets and prototypes.
- **Styled-Components**: For styling React components with a modular, themable approach.
- **Storybook**: For building an interactive, documented component library.
## Challenges and Learnings
One key challenge was balancing the need for flexibility with the desire to maintain design consistency. The solution involved creating well-defined design tokens and establishing clear guidelines for when and how components could be customized. Additionally, setting up effective collaboration workflows between designers and developers using Figma and Git was a learning experience that greatly improved the process.
## Outcome
The design system is now actively used across multiple projects, leading to faster development cycles, fewer design inconsistencies, and improved collaboration between design and development teams. It has become a foundation for scaling our products efficiently while ensuring a cohesive user experience.

View File

@@ -0,0 +1,43 @@
---
title: "Automating Design Handovers with a Figma to Code Pipeline"
publishedAt: "2024-04-01"
summary: "Explore the enduring debate between using spaces and tabs for code indentation, and why this choice matters more than you might think."
images:
- "/images/projects/project-01/cover-02.jpg"
- "/images/projects/project-01/image-03.jpg"
team:
- name: "John Doe"
role: "Software Engineer"
avatar: "/images/avatar.jpg"
linkedIn: "https://www.linkedin.com/company/once-ui/"
---
## Overview
In this project, I focused on automating the often tedious design handover process. The goal was to create a pipeline that converts Figma designs directly into clean, production-ready code. By integrating design tokens, component libraries, and automated workflows, this solution significantly reduced the time spent on translating design assets into code, while maintaining design consistency across the product.
## Key Features
- **Figma Plugin Integration**: Developed a custom Figma plugin that extracts design tokens such as colors, typography, and spacing values, and exports them in a format compatible with our codebase.
- **Code Generation**: Integrated an automated process that translates Figma components into React code using a combination of design tokens and pre-built component templates. This allowed developers to focus more on logic and less on repetitive UI coding.
- **Continuous Sync**: Established a CI/CD pipeline that continuously synchronizes design changes from Figma to the codebase, ensuring design updates are reflected instantly without manual intervention.
- **Scalable Design System**: Leveraged a design system that remains the single source of truth for both designers and developers, making it easy to maintain consistency even as the product evolves.
## Technologies Used
- **Figma API**: For extracting design tokens and component data directly from the Figma designs.
- **React and Next.js**: For building the front-end codebase with clean, reusable components.
- **Styled-Components**: For managing styles dynamically using design tokens.
- **GitHub Actions**: For automating the pipeline and syncing design changes to the repository.
## Challenges and Learnings
One of the biggest challenges was ensuring that the generated code was clean and maintainable. This involved setting up intelligent mapping between Figma components and React code structures, as well as managing edge cases like responsive design and conditional rendering. Additionally, the continuous synchronization required a robust error-handling system to prevent conflicts during development.
## Outcome
The automated Figma to code pipeline has streamlined the handoff process, cutting down design-to-development time by 40%. Designers now have more confidence that their designs will be accurately translated into code, and developers can focus on more complex logic and feature development. This project has proven the value of automation in bridging the gap between design and development.
---
This project demonstrates your ability to leverage automation and streamline workflows, which is highly relevant for design engineering portfolios focused on efficiency and innovation.

View File

@@ -0,0 +1,6 @@
---
title: "Simple portfolio creator built with Once UI and Next.js"
publishedAt: "2024-04-08"
images:
- "/images/projects/project-01/video-01.mp4"
---

View File

@@ -0,0 +1,125 @@
"use client";
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react';
import { Flex, Icon, Heading } from '.';
interface AccordionProps {
title: React.ReactNode;
children: React.ReactNode;
style?: React.CSSProperties;
className?: string;
open?: boolean;
}
const Accordion: React.FC<AccordionProps> = forwardRef(({
title,
children,
style,
className,
open = false
}, ref) => {
const [isOpen, setIsOpen] = useState(open);
const [maxHeight, setMaxHeight] = useState(open ? 'none' : '0px');
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) {
setIsOpen(true);
setMaxHeight(`${contentRef.current?.scrollHeight}px`);
} else {
setIsOpen(false);
setMaxHeight('0px');
}
}, [open]);
const toggleAccordion = () => {
if (isOpen) {
setMaxHeight('0px');
} else {
setMaxHeight(`${contentRef.current?.scrollHeight}px`);
}
setIsOpen(!isOpen);
};
useImperativeHandle(ref, () => ({
toggle: toggleAccordion,
open: () => {
setIsOpen(true);
setMaxHeight(`${contentRef.current?.scrollHeight}px`);
},
close: () => {
setIsOpen(false);
setMaxHeight('0px');
}
}));
useEffect(() => {
const handleTransitionEnd = () => {
if (isOpen) {
setMaxHeight('none');
}
};
const contentElement = contentRef.current;
if (contentElement) {
contentElement.addEventListener('transitionend', handleTransitionEnd);
}
return () => {
if (contentElement) {
contentElement.removeEventListener('transitionend', handleTransitionEnd);
}
};
}, [isOpen]);
return (
<Flex
fillWidth
direction="column"
style={style}
className={className}>
<Flex
style={{ borderTop: "1px solid var(--neutral-border-medium)", cursor: 'pointer' }}
paddingY="16"
paddingLeft="m"
paddingRight="m"
alignItems="center"
justifyContent="space-between"
tabIndex={0}
onClick={toggleAccordion}
aria-expanded={isOpen}
aria-controls="accordion-content">
<Heading
as="h3"
variant="heading-strong-s">
{title}
</Heading>
<Icon
name="chevronDown"
size="m"
style={{ display: 'flex', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform var(--transition-duration-micro-medium)' }} />
</Flex>
<div
id="accordion-content"
ref={contentRef}
style={{
maxHeight,
overflow: 'hidden',
transition: 'max-height var(--transition-duration-macro-long) var(--transition-timing-function)',
visibility: isOpen ? 'visible' : 'hidden'
}}
aria-hidden={!isOpen}>
<Flex
paddingX="m"
paddingBottom="32"
direction="column">
{children}
</Flex>
</div>
</Flex>
);
});
Accordion.displayName = "Accordion";
export { Accordion };

View File

@@ -0,0 +1,60 @@
.avatar {
&.xs {
width: var(--static-space-20);
height: var(--static-space-20);
min-width: var(--static-space-20);
min-height: var(--static-space-20);
}
&.s {
width: var(--static-space-24);
height: var(--static-space-24);
min-width: var(--static-space-24);
min-height: var(--static-space-24);
}
&.m {
width: var(--static-space-32);
height: var(--static-space-32);
min-width: var(--static-space-32);
min-height: var(--static-space-32);
}
&.l {
width: var(--static-space-48);
height: var(--static-space-48);
min-width: var(--static-space-48);
min-height: var(--static-space-48);
}
&.xl {
width: var(--static-space-160);
height: var(--static-space-160);
min-width: var(--static-space-160);
min-height: var(--static-space-160);
.position {
bottom: var(--static-space-16);
right: var(--static-space-16);
}
}
.value {
white-space: nowrap;
overflow: hidden;
user-select: none;
}
}
.indicator {
box-sizing: content-box;
position: absolute;
bottom: 0;
right: 0;
transform: translateX(var(--static-space-2)) translateY(var(--static-space-2));
}
.image {
border-radius: var(--radius-999);
object-position: center;
}

View File

@@ -0,0 +1,131 @@
"use client";
import React, { forwardRef } from 'react';
import { Skeleton, Icon, Text, StatusIndicator, Flex, SmartImage } from '.';
import styles from './Avatar.module.scss';
interface AvatarProps {
size?: 'xs' | 's' | 'm' | 'l' | 'xl';
value?: string;
src?: string;
loading?: boolean;
empty?: boolean;
statusIndicator?: {
color: 'green' | 'yellow' | 'red' | 'gray';
};
style?: React.CSSProperties;
className?: string;
}
const sizeMapping: Record<'xs' | 's' | 'm' | 'l' | 'xl', number> = {
xs: 20,
s: 24,
m: 32,
l: 48,
xl: 160,
};
const statusIndicatorSizeMapping: Record<'xs' | 's' | 'm' | 'l' | 'xl', 's' | 'm' | 'l'> = {
xs: 's',
s: 's',
m: 'm',
l: 'm',
xl: 'l',
};
const Avatar: React.FC<AvatarProps> = forwardRef<HTMLDivElement, AvatarProps>(({
size = 'm',
value,
src,
loading,
empty,
statusIndicator,
style,
className
}, ref) => {
const isEmpty = empty || (!src && !value);
if (value && src) {
throw new Error("Avatar cannot have both 'value' and 'src' props.");
}
if (loading) {
return (
<Skeleton
style={{border: '1px solid var(--neutral-border-medium)'}}
shape="circle"
width={size}
height={size}
className={`${styles.avatar} ${className}`}
aria-busy="true"
aria-label="Loading avatar"/>
);
}
const renderContent = () => {
if (isEmpty) {
return <Icon
onBackground="neutral-medium"
name="person"
size={size as 'xs' | 's' | 'm' | 'l' | 'xl'}
className={styles.icon}
aria-label="Empty avatar"/>;
}
if (src) {
return (
<SmartImage
src={src}
fill
alt="Avatar"
sizes={`${sizeMapping[size]}px`}
className={styles.image}/>
);
}
if (value) {
return (
<Text
as="span"
onBackground="neutral-weak"
variant={`body-default-${size}`}
className={styles.value}
aria-label={`Avatar with initials ${value}`}>
{value}
</Text>
);
}
return null;
};
return (
<Flex
ref={ref}
position="relative"
justifyContent="center"
alignItems="center"
radius="full"
border="neutral-strong"
borderStyle="solid-1"
background="surface"
style={style}
role="img"
className={`${styles.avatar} ${styles[size]} ${className || ''}`}>
{renderContent()}
{statusIndicator && (
<StatusIndicator
size={statusIndicatorSizeMapping[size]}
color={statusIndicator.color}
className={`${styles.className || ''} ${styles.indicator} ${size === 'xl' ? styles.position : ''}`}
aria-label={`Status: ${statusIndicator.color}`}/>
)}
</Flex>
);
});
Avatar.displayName = "Avatar";
export { Avatar };
export type { AvatarProps };

View File

@@ -0,0 +1,8 @@
.avatar {
position: relative;
margin-left: calc(-1 * var(--static-space-8));
&:first-child {
margin-left: 0;
}
}

View File

@@ -0,0 +1,64 @@
"use client";
import React, { forwardRef } from 'react';
import { Avatar, AvatarProps, Flex } from '.';
import styles from './AvatarGroup.module.scss';
interface AvatarGroupProps {
avatars: AvatarProps[];
size?: 'xs' | 's' | 'm' | 'l' | 'xl';
reverseOrder?: boolean;
limit?: number;
className?: string;
style?: React.CSSProperties;
}
const AvatarGroup = forwardRef<HTMLDivElement, AvatarGroupProps>(({
avatars,
size = 'm',
reverseOrder = false,
limit,
className,
style
}, ref) => {
const displayedAvatars = limit ? avatars.slice(0, limit) : avatars;
const remainingCount = limit && avatars.length > limit ? avatars.length - limit : 0;
return (
<Flex
position="relative"
alignItems="center"
ref={ref}
className={`${styles.avatarGroup} ${className || ''}`}
style={style}
zIndex={0}>
{displayedAvatars.map((avatarProps, index) => (
<Avatar
key={index}
size={size}
{...avatarProps}
className={styles.avatar}
style={{
...avatarProps.style,
zIndex: reverseOrder ? displayedAvatars.length - index : index + 1
}}/>
))}
{remainingCount > 0 && (
<Avatar
value={`+${remainingCount}`}
className={styles.avatar}
size={size}
style={{
...style,
zIndex: reverseOrder ? -1 : displayedAvatars.length + 1
}}/>
)}
</Flex>
);
});
AvatarGroup.displayName = "AvatarGroup";
export { AvatarGroup };
export type { AvatarGroupProps };

View File

@@ -0,0 +1,180 @@
"use client";
import React, { CSSProperties, forwardRef, useEffect, useState } from 'react';
import GridPattern from "@/once-ui/components/GridPatterns"; // Pas het pad aan indien nodig
interface BackgroundProps {
position?: CSSProperties['position'];
gradient?: boolean;
dots?: boolean;
lines?: boolean;
shootingStars?: boolean; // New prop for shooting stars
gridpatterns?: boolean; // Voeg de prop toe voor gridpatterns
className?: string;
style?: React.CSSProperties;
}
const Background = forwardRef<HTMLDivElement, BackgroundProps>(({
position = 'fixed',
gradient = true,
dots = true,
lines = true,
shootingStars = false,
gridpatterns = true,
className,
style
}, ref) => {
const [stars, setStars] = useState<Array<{ id: number, top: string, left: string }>>([]);
useEffect(() => {
if (shootingStars) {
const interval = setInterval(() => {
const newStar = {
id: Math.random(),
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
};
setStars((prevStars) => [...prevStars, newStar]);
setTimeout(() => {
setStars((prevStars) => prevStars.filter((star) => star.id !== newStar.id));
}, 5000);
}, Math.random() * 5000 + 5000);
return () => clearInterval(interval);
}
}, [shootingStars]);
return (
<>
{gradient && (
<div
ref={ref}
className={className}
style={{
position: position,
top: '0',
left: '0',
zIndex: '0',
width: '100%',
height: '100%',
filter: 'contrast(1.5)',
background: 'radial-gradient(100% 100% at 49.99% 0%, var(--static-transparent) 0%, var(--page-background) 100%), radial-gradient(87.4% 84.04% at 6.82% 16.24%, var(--brand-background-medium) 0%, var(--static-transparent) 100%), radial-gradient(217.89% 126.62% at 48.04% 0%, var(--accent-solid-medium) 0%, var(--static-transparent) 100%)',
...style,
}}
/>
)}
{dots && (
<div
ref={ref}
className={className}
style={{
position: position,
zIndex: '0',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundImage: 'radial-gradient(var(--brand-on-background-weak) 0.5px, var(--static-transparent) 0.5px)',
opacity: '0.25',
backgroundSize: 'var(--static-space-16) var(--static-space-16)',
...style,
}}
/>
)}
{lines && (
<div
ref={ref}
className={className}
style={{
position: position,
zIndex: '0',
top: '0',
left: '0',
width: '100%',
height: '100%',
backgroundImage: 'repeating-linear-gradient(45deg, var(--brand-on-background-weak) 0, var(--brand-on-background-weak) 0.5px, var(--static-transparent) 0.5px, var(--static-transparent) var(--static-space-8))',
maskImage: 'linear-gradient(to bottom left, rgba(0, 0, 0, 1) 30%, rgba(0, 0, 0, 0) 70%)',
maskSize: '100% 100%',
maskPosition: 'top right',
maskRepeat: 'no-repeat',
opacity: '0.2',
...style,
}}
/>
)}
{/* Voeg hier de ontbrekende props toe aan de GridPattern */}
{gridpatterns && (
<GridPattern
className={className} // De className prop voor extra styling
width={40} // Breedte van het patroon
height={40} // Hoogte van het patroon
numSquares={100} // Aantal vierkanten in het patroon
maxOpacity={0.2} // Maximale opacity van het patroon
duration={4} // Duur van de animatie
repeatDelay={0.5} // Vertraagde herhaling van de animatie
x={-1} // X-offset van het patroon
y={-1} // Y-offset van het patroon
strokeDasharray={0} // Stroke dash array voor het patroon
style={{
position: position,
zIndex: '0',
top: '0',
left: '0',
width: '200%',
height: '200%',
transform: 'skewY(12deg)'
}}
/>
)}
{shootingStars && stars.map((star) => (
<div
key={star.id}
className={className}
style={{
position: 'absolute',
top: star.top,
left: star.left,
width: '1px',
height: '1px',
background: 'linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0))',
animation: 'shooting-star 5s ease-out forwards',
perspective: '1000px',
...style,
}}
/>
))}
<style jsx>{`
@keyframes shooting-star {
0% {
width: 1px;
opacity: 0;
transform: translateZ(-1000px) scale(0.1) rotateY(45deg);
transform-origin: left center;
}
20% {
width: 400px;
opacity: 1;
transform: translateZ(-500px) scale(0.5) rotateY(45deg);
}
80% {
width: 400px;
opacity: 1;
transform: translateZ(0px) scale(1) rotateY(45deg);
}
100% {
width: 0px;
opacity: 0;
transform: translateZ(300px) scale(2) rotateY(45deg);
transform-origin: left center;
}
}
`}</style>
</>
);
});
Background.displayName = 'Background';
export { Background };

View File

@@ -0,0 +1,116 @@
.button {
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
user-select: none;
padding: 0;
white-space: nowrap;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
&:disabled {
box-shadow: none;
background: var(--neutral-solid-weak);
color: var(--neutral-on-solid-weak);
border: none;
pointer-events: none;
cursor: not-allowed;
}
}
.primary {
box-shadow: inset 0 var(--solid-inset-distance) var(--solid-inset-size) var(--solid-inset-color-brand);
background: var(--brand-solid-medium);
border-style: solid;
border-width: var(--solid-border-width);
border-color: var(--solid-border-color-brand);
color: var(--brand-on-solid-strong);
&:hover, &:focus {
background: var(--brand-solid-strong);
}
}
.secondary {
box-shadow: inset 0 var(--solid-inset-distance) var(--solid-inset-size) var(--solid-inset-color-neutral);
background: var(--neutral-background-medium);
border-style: solid;
border: 1px solid var(--neutral-border-medium);
color: var(--neutral-on-background-strong);
&:hover, &:focus {
background: var(--neutral-background-strong);
border-color: var(--neutral-border-strong);
}
}
.tertiary {
box-shadow: inset 0 var(--solid-inset-distance) var(--solid-inset-size) var(--solid-inset-color-neutral);
background: var(--static-transparent);
border: 1px solid var(--neutral-border-medium);
color: var(--neutral-on-background-strong);
&:hover, &:focus {
background: var(--neutral-alpha-weak);
border-color: var(--neutral-border-strong);
}
}
.ghost {
border: none;
background: var(--static-transparent);
color: var(--neutral-on-background-medium);
&:hover, &:focus {
color: var(--neutral-on-background-strong);
}
}
.danger {
box-shadow: inset 0 var(--solid-inset-distance) var(--solid-inset-size) var(--solid-inset-color-danger);
background: var(--danger-solid-medium);
border-style: solid;
border-width: var(--solid-border-width);
border-color: var(--solid-border-color-danger);
color: var(--danger-on-solid-strong);
&:hover, &:focus {
background: var(--danger-solid-strong);
}
}
.s {
padding: var(--static-space-4) var(--static-space-8);
border-radius: var(--radius-m);
height: var(--static-space-32);
gap: var(--static-space-4);
}
.m {
padding: var(--static-space-8) var(--static-space-12);
border-radius: var(--radius-m);
height: var(--static-space-40);
gap: var(--static-space-4);
}
.l {
padding: var(--static-space-12) var(--static-space-20);
border-radius: var(--radius-l);
height: var(--static-space-48);
gap: var(--static-space-8);
}
.label {
padding: 0 var(--static-space-4);
}
.fillWidth {
width: 100%;
}
.fitContent {
width: fit-content;
}

View File

@@ -0,0 +1,99 @@
"use client";
import React, { ReactNode, forwardRef } from 'react';
import Link from 'next/link';
import { Spinner, Icon } from '.';
import styles from './Button.module.scss';
interface CommonProps {
variant?: 'primary' | 'secondary' | 'tertiary' | 'danger';
size?: 's' | 'm' | 'l';
label?: string;
prefixIcon?: string;
suffixIcon?: string;
loading?: boolean;
fillWidth?: boolean;
children?: ReactNode;
href?: string;
className?: string;
style?: React.CSSProperties;
}
export type ButtonProps = CommonProps & React.ButtonHTMLAttributes<HTMLButtonElement>;
export type AnchorProps = CommonProps & React.AnchorHTMLAttributes<HTMLAnchorElement>;
const isExternalLink = (url: string) => /^https?:\/\//.test(url);
const Button = forwardRef<HTMLButtonElement, ButtonProps | AnchorProps>(({
variant = 'primary',
size = 'm',
label,
children,
prefixIcon,
suffixIcon,
loading = false,
fillWidth = false,
href,
className,
style,
...props
}, ref) => {
const labelSize = size === 'l' ? 'font-l' : size === 'm' ? 'font-m' : 'font-s';
const iconSize = size === 'l' ? 'm' : size === 'm' ? 's' : 'xs';
const content = (
<>
{prefixIcon && !loading && <Icon name={prefixIcon} size={iconSize} />}
{loading && <Spinner size={size} />}
<div className={`font-label font-strong ${styles.label} ${labelSize}`}>{label || children}</div>
{suffixIcon && <Icon name={suffixIcon} size={iconSize} />}
</>
);
const commonProps = {
className: `${styles.button} ${styles[variant]} ${styles[size]} ${fillWidth ? styles.fillWidth : styles.fitContent} ${className || ''}`,
style: { ...style, textDecoration: 'none' },
};
if (href) {
const isExternal = isExternalLink(href);
if (isExternal) {
return (
<a
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
target="_blank"
rel="noreferrer"
{...commonProps}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
{content}
</a>
);
}
return (
<Link
href={href}
ref={ref as React.Ref<HTMLAnchorElement>}
{...commonProps}
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
{content}
</Link>
);
}
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
{...commonProps}
{...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}>
{content}
</button>
);
});
Button.displayName = 'Button';
export { Button };

View File

@@ -0,0 +1,74 @@
.container {
cursor: pointer;
isolation: isolate;
z-index: 1;
&:hover, &:focus {
.checkbox.checked .checkbox::before {
display: none;
}
}
}
.checkbox {
box-shadow: inset 0 0 0 var(--solid-inset-color-brand);
border-color: var(--neutral-border-medium);
border-style: solid;
border-width: 1px;
border-radius: min(var(--static-space-4), var(--radius-xs));
width: var(--static-space-20);
height: var(--static-space-20);
min-width: var(--static-space-20);
min-height: var(--static-space-20);
transition: var(--transition-property) var(--transition-duration-micro-medium) var(--transition-timing-function);
outline: none;
&.checked {
box-shadow: inset 0 var(--solid-inset-distance) var(--solid-inset-size) var(--solid-inset-color-brand);
background-color: var(--brand-solid-medium);
border-color: var(--solid-border-color-brand);
}
}
.container:hover .checkbox::before,
.checkbox:focus-within::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--static-space-40);
height: var(--static-space-40);
background-color: var(--brand-alpha-medium);
border-radius: var(--radius-999);
z-index: -1;
animation: scaleInCenter 0.2s forwards;
}
@keyframes scaleInCenter {
from {
transform: translate(-50%, -50%) scale(0);
}
to {
transform: translate(-50%, -50%) scale(1);
}
}
.icon {
animation: scaleIn 0.2s forwards;
}
.indeterminate {
background: var(--brand-on-solid-strong);
width: var(--static-space-12);
height: var(--static-space-2);
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}

View File

@@ -0,0 +1,95 @@
"use client";
import React, { useState, useEffect, forwardRef } from 'react';
import classNames from 'classnames';
import { Flex, Icon, InteractiveDetails, InteractiveDetailsProps } from '.';
import styles from './Checkbox.module.scss';
interface CheckboxProps extends Omit<InteractiveDetailsProps, 'onClick'> {
style?: React.CSSProperties;
className?: string;
isChecked?: boolean;
isIndeterminate?: boolean;
onToggle?: () => void;
}
const generateId = () => `checkbox-${Math.random().toString(36).substring(2, 9)}`;
const Checkbox: React.FC<CheckboxProps> = forwardRef<HTMLDivElement, CheckboxProps>(({
style,
className,
isChecked: controlledIsChecked,
isIndeterminate = false,
onToggle,
...interactiveDetailsProps
}, ref) => {
const [isChecked, setIsChecked] = useState(controlledIsChecked || false);
const [checkboxId] = useState(generateId());
useEffect(() => {
if (controlledIsChecked !== undefined) {
setIsChecked(controlledIsChecked);
}
}, [controlledIsChecked]);
const toggleItem = () => {
if (onToggle) {
onToggle();
} else {
setIsChecked(!isChecked);
}
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleItem();
}
};
return (
<Flex
ref={ref}
alignItems="center"
gap="16"
style={style}
className={classNames(styles.container, className)}
onClick={toggleItem}>
<Flex
role="checkbox"
aria-checked={isIndeterminate ? 'mixed' : (controlledIsChecked !== undefined ? controlledIsChecked : isChecked)}
aria-labelledby={checkboxId}
position="relative"
justifyContent="center"
alignItems="center"
background="surface"
onKeyDown={handleKeyDown}
tabIndex={0}
className={classNames(styles.checkbox, {
[styles.checked]: controlledIsChecked !== undefined ? controlledIsChecked || isIndeterminate : isChecked,
})}>
{(controlledIsChecked !== undefined ? controlledIsChecked : isChecked) && !isIndeterminate && (
<Icon
onSolid="brand-strong"
name="check"
size="xs"
className={styles.icon}/>
)}
{isIndeterminate && (
<Flex
radius="full"
className={`${styles.icon} ${styles.indeterminate}`}/>
)}
</Flex>
<InteractiveDetails
id={checkboxId}
{...interactiveDetailsProps}
onClick={toggleItem}/>
</Flex>
);
});
Checkbox.displayName = "Checkbox";
export { Checkbox };
export type { CheckboxProps };

Some files were not shown because too many files have changed in this diff Show More