Initail_Commit
7
.env.example
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: lorant
|
||||||
36
.gitignore
vendored
Normal 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
@@ -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
@@ -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**
|
||||||
|
[](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
@@ -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
44
package.json
Normal 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
@@ -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
BIN
public/images/avatar.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/images/gallery/img-01.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
public/images/gallery/img-02.jpg
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
public/images/gallery/img-03.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
public/images/gallery/img-04.jpg
Normal file
|
After Width: | Height: | Size: 437 KiB |
BIN
public/images/gallery/img-05.jpg
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
public/images/gallery/img-06.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
public/images/gallery/img-07.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/images/gallery/img-08.jpg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/images/gallery/img-09.jpg
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
public/images/gallery/img-10.jpg
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
public/images/gallery/img-11.jpg
Normal file
|
After Width: | Height: | Size: 230 KiB |
BIN
public/images/gallery/img-12.jpg
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
public/images/gallery/img-13.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
public/images/gallery/img-14.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
public/images/projects/project-01/avatar-01.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/images/projects/project-01/avatar-02.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/images/projects/project-01/avatar-03.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/images/projects/project-01/avatar-04.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/images/projects/project-01/avatar-05.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/images/projects/project-01/cover-01.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
public/images/projects/project-01/cover-02.jpg
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
public/images/projects/project-01/cover-03.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
public/images/projects/project-01/cover-04.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/images/projects/project-01/image-01.jpg
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
public/images/projects/project-01/image-02.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
public/images/projects/project-01/image-03.jpg
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
public/images/projects/project-01/video-01.mp4
Normal file
6
public/trademark/icon-dark.svg
Normal 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 |
6
public/trademark/icon-light.svg
Normal 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 |
8
public/trademark/type-dark.svg
Normal 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 |
8
public/trademark/type-light.svg
Normal 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 |
17
src/app/about/about.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/app/about/components/TableOfContents.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/app/blog/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/blog/components/Posts.module.scss
Normal 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);
|
||||||
|
}
|
||||||
67
src/app/blog/components/Posts.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/app/blog/posts/new-milestone-in-my-career.mdx
Normal 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, I’m excited to share that I’ve 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, it’s 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. I’ve 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—it’s a moment of personal growth. It’s a sign that the dedication and passion I’ve invested are paying off. Whether it’s 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 it’s exploring emerging technologies, diving deeper into specific fields of interest, or taking on a mentorship role, I’m 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
|
||||||
|
|
||||||
|
I’d be remiss if I didn’t 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 I’m proud of how far I’ve come, I know this is just one milestone in a much larger journey. The road ahead is filled with exciting possibilities, and I’m eager to continue pushing boundaries, learning new things, and contributing to meaningful projects. If there’s one thing I’ve learned along the way, it’s 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 here’s to the adventures yet to come!
|
||||||
@@ -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
|
||||||
|
|
||||||
|
It’s easy to think of discarded designs as failures, but in truth, they’re 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 doesn’t.
|
||||||
|
|
||||||
|
## The Process of Elimination
|
||||||
|
|
||||||
|
In every project, the first few ideas often come quickly. They’re 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 didn’t make it, there’s 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 didn’t 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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 you’re stuck or looking for inspiration, don’t be afraid to dig into the drawer. The answer might be hiding there, waiting for the right moment to shine.
|
||||||
32
src/app/blog/posts/the-rise-of-design-engineering.mdx
Normal 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, what’s 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. It’s 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 it’s a smartphone, a medical device, or an automotive system, today’s 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 everyone’s 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. It’s 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.
|
||||||
48
src/app/components/Footer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/app/components/Header.module.scss
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.position {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (--s) {
|
||||||
|
.position {
|
||||||
|
top: auto;
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--static-space-24);
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/app/components/Header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/app/components/HeadingLink.module.scss
Normal 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);
|
||||||
|
}
|
||||||
87
src/app/components/HeadingLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
134
src/app/components/Mailchimp.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
0
src/app/components/ProjectCard.module.scss
Normal file
153
src/app/components/ProjectCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
122
src/app/components/RouteGuard.tsx
Normal 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 };
|
||||||
6
src/app/components/index.ts
Normal 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
@@ -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
|
After Width: | Height: | Size: 264 KiB |
14
src/app/gallery/Gallery.module.scss
Normal 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);
|
||||||
|
}
|
||||||
33
src/app/gallery/components/MasonryGrid.tsx
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/resources/config-blog.js
Normal 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 };
|
||||||
49
src/app/resources/config-minimal.js
Normal 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 };
|
||||||
51
src/app/resources/config.js
Normal 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 };
|
||||||
258
src/app/resources/content-blog.js
Normal 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. I’m 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 };
|
||||||
258
src/app/resources/content-minimal.js
Normal 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. I’m 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 };
|
||||||
300
src/app/resources/content.js
Normal 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 };
|
||||||
3
src/app/resources/index.ts
Normal 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
@@ -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
@@ -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
@@ -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})`;
|
||||||
|
}
|
||||||
149
src/app/work/[slug]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/app/work/components/Projects.module.scss
Normal 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);
|
||||||
|
}
|
||||||
37
src/app/work/components/Projects.tsx
Normal 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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
6
src/app/work/projects/simple-portfolio-builder.mdx
Normal 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"
|
||||||
|
---
|
||||||
125
src/once-ui/components/Accordion.tsx
Normal 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 };
|
||||||
60
src/once-ui/components/Avatar.module.scss
Normal 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;
|
||||||
|
}
|
||||||
131
src/once-ui/components/Avatar.tsx
Normal 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 };
|
||||||
8
src/once-ui/components/AvatarGroup.module.scss
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
margin-left: calc(-1 * var(--static-space-8));
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/once-ui/components/AvatarGroup.tsx
Normal 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 };
|
||||||
0
src/once-ui/components/Background.module.scss
Normal file
180
src/once-ui/components/Background.tsx
Normal 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 };
|
||||||
116
src/once-ui/components/Button.module.scss
Normal 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;
|
||||||
|
}
|
||||||
99
src/once-ui/components/Button.tsx
Normal 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 };
|
||||||
74
src/once-ui/components/Checkbox.module.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/once-ui/components/Checkbox.tsx
Normal 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 };
|
||||||