rewrite
@@ -1,3 +0,0 @@
|
||||
/node_modules/
|
||||
/.astro/
|
||||
/.vscode/
|
||||
4
.github/workflows/release.yaml
vendored
@@ -27,11 +27,11 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: "10.18"
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "23"
|
||||
cache: "pnpm"
|
||||
- name: Setup Pages
|
||||
id: pages
|
||||
|
||||
6
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
/.pnpm-store/
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
@@ -13,10 +13,12 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pnpm lint-staged
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import("prettier").Config} */
|
||||
module.exports = {
|
||||
...require('prettier-config-standard'),
|
||||
plugins: [require.resolve('prettier-plugin-astro')],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.astro',
|
||||
options: {
|
||||
parser: 'astro'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"arrowParens": "always",
|
||||
"bracketSpacing": true,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"insertPragma": false,
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"printWidth": 120,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"useTabs": false,
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
14
.u8.json
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"values": {
|
||||
"monoRepo": false
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"timestamp": "2025-09-23T20:22:40.143Z",
|
||||
"template": "eslint",
|
||||
"values": {
|
||||
"monoRepo": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
2
.vscode/extensions.json
vendored
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "esbenp.prettier-vscode"],
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
14
.vscode/settings.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"astro",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"prettier.documentSelectors": ["**/*.astro"],
|
||||
"[astro]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
47
README.md
@@ -1,47 +0,0 @@
|
||||
# Astro Starter Kit: Minimal
|
||||
|
||||
```sh
|
||||
npm create astro@latest -- --template minimal
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
/
|
||||
├── public/
|
||||
├── src/
|
||||
│ └── pages/
|
||||
│ └── index.astro
|
||||
└── package.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
@@ -21,10 +21,19 @@ const getSiteInfo = () => {
|
||||
export default defineConfig({
|
||||
...getSiteInfo(),
|
||||
output: 'static',
|
||||
integrations: [mdx(), sitemap(), icon(), compress(), robotsTxt()],
|
||||
integrations: [mdx(), sitemap(), icon(), compress({
|
||||
HTML: false,
|
||||
}), robotsTxt()],
|
||||
devToolbar: {
|
||||
enabled: false,
|
||||
},
|
||||
build: {
|
||||
inlineStylesheets: 'always',
|
||||
},
|
||||
vite: {
|
||||
build: {
|
||||
assetsInlineLimit: 1024 * 10
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Content } from './description.md';
|
||||
import image from './profile.jpg';
|
||||
|
||||
import type { ResumeSchema } from '@/types/resume-schema.js';
|
||||
|
||||
const basics = {
|
||||
name: 'Morten Olsen',
|
||||
tagline: "Hi, I'm Morten and I make software 👋",
|
||||
email: 'fbtijfdq@void.black',
|
||||
url: 'https://mortenolsen.pro',
|
||||
image: image.src,
|
||||
location: {
|
||||
city: 'Copenhagen',
|
||||
countryCode: 'DK',
|
||||
region: 'Capital Region of Denmark',
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
network: 'GitHub',
|
||||
icon: 'mdi:github',
|
||||
username: 'morten-olsen',
|
||||
url: 'https://github.com/morten-olsen',
|
||||
},
|
||||
{
|
||||
network: 'LinkedIn',
|
||||
icon: 'mdi:linkedin',
|
||||
username: 'mortenolsendk',
|
||||
url: 'https://www.linkedin.com/in/mortenolsendk',
|
||||
},
|
||||
],
|
||||
languages: [
|
||||
{
|
||||
name: 'English',
|
||||
fluency: 'Conversational',
|
||||
},
|
||||
{
|
||||
name: 'Danish',
|
||||
fluency: 'Native speaker',
|
||||
},
|
||||
],
|
||||
} satisfies ResumeSchema['basics'];
|
||||
|
||||
const profile = {
|
||||
basics,
|
||||
image,
|
||||
Content,
|
||||
};
|
||||
|
||||
export { profile };
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
title: Bob the algorithm
|
||||
link: /articles/bob-the-algorithm
|
||||
keywords:
|
||||
- Typescript
|
||||
- React Native
|
||||
- Algorithmic
|
||||
---
|
||||
|
||||
`// TODO`
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
title: Bob the algorithm
|
||||
link: https://github.com/morten-olsen/mini-loader
|
||||
keywords:
|
||||
- Typescript
|
||||
- Task management
|
||||
---
|
||||
|
||||
`// TODO`
|
||||
17
devbox.json
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.10.5/.schema/devbox.schema.json",
|
||||
"packages": [
|
||||
"nodejs@21"
|
||||
],
|
||||
"env": {
|
||||
"DEVBOX_COREPACK_ENABLED": "true"
|
||||
},
|
||||
"shell": {
|
||||
"init_hook": [],
|
||||
"scripts": {
|
||||
"test": [
|
||||
"echo \"Error: no test specified\" && exit 1"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
70
devbox.lock
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"lockfile_version": "1",
|
||||
"packages": {
|
||||
"nodejs@21": {
|
||||
"last_modified": "2024-03-22T07:26:23-04:00",
|
||||
"plugin_version": "0.0.2",
|
||||
"resolved": "github:NixOS/nixpkgs/a3ed7406349a9335cb4c2a71369b697cecd9d351#nodejs_21",
|
||||
"source": "devbox-search",
|
||||
"version": "21.7.1",
|
||||
"systems": {
|
||||
"aarch64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/x1d9im8iy3q74jx1ij2k3pjsfgvqihn1-nodejs-21.7.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "libv8",
|
||||
"path": "/nix/store/q6nyy20l3ixkc6j20sng8vfdjbx3fx3l-nodejs-21.7.1-libv8"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/x1d9im8iy3q74jx1ij2k3pjsfgvqihn1-nodejs-21.7.1"
|
||||
},
|
||||
"aarch64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/b7cq5jvw90bl4ls3nssrj5xwh3d6vldf-nodejs-21.7.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "libv8",
|
||||
"path": "/nix/store/w6z9pd67lv405dv366zbfn7cyvf8r43z-nodejs-21.7.1-libv8"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/b7cq5jvw90bl4ls3nssrj5xwh3d6vldf-nodejs-21.7.1"
|
||||
},
|
||||
"x86_64-darwin": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/klvzykcgrhlbpkgdaw5329w2l09wp4vd-nodejs-21.7.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "libv8",
|
||||
"path": "/nix/store/3nxs6fcwrbllix8zwdgpw52wg022h4mm-nodejs-21.7.1-libv8"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/klvzykcgrhlbpkgdaw5329w2l09wp4vd-nodejs-21.7.1"
|
||||
},
|
||||
"x86_64-linux": {
|
||||
"outputs": [
|
||||
{
|
||||
"name": "out",
|
||||
"path": "/nix/store/7fd8ac3wm7gq8k5qd6l15hqx13bm4mr6-nodejs-21.7.1",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "libv8",
|
||||
"path": "/nix/store/pxmz45ki1zrp6lzfypcjyhgk6v9mpk55-nodejs-21.7.1-libv8"
|
||||
}
|
||||
],
|
||||
"store_path": "/nix/store/7fd8ac3wm7gq8k5qd6l15hqx13bm4mr6-nodejs-21.7.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
version: '3'
|
||||
services:
|
||||
dev:
|
||||
build:
|
||||
context: ./docker
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./:/app
|
||||
ports:
|
||||
- 4321:4321
|
||||
command: [ pnpm, dev, '--host' ]
|
||||
@@ -1,3 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
RUN corepack enable
|
||||
USER 1000
|
||||
@@ -1,46 +0,0 @@
|
||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import eslint from '@eslint/js';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.strict,
|
||||
...tseslint.configs.stylistic,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [importPlugin.flatConfigs.recommended, importPlugin.flatConfigs.typescript],
|
||||
rules: {
|
||||
'import/no-unresolved': 'off',
|
||||
'import/extensions': ['error', 'ignorePackages'],
|
||||
'import/exports-last': 'error',
|
||||
'import/no-default-export': 'error',
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
],
|
||||
'import/no-duplicates': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
},
|
||||
},
|
||||
...eslintPluginAstro.configs.recommended,
|
||||
{
|
||||
ignores: ['**/node_modules/', '**/dist/', '**/.turbo/', '**/generated/'],
|
||||
},
|
||||
);
|
||||
72
package.json
@@ -1,68 +1,28 @@
|
||||
{
|
||||
"name": "morten-olsen-github-io",
|
||||
"name": "private-webpage",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"test:lint": "eslint",
|
||||
"docker:install": "docker-compose -f docker-compose.dev.yml run --rm dev pnpm install",
|
||||
"docker:dev": "docker-compose -f docker-compose.dev.yml up",
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"lint": "prettier \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\" && eslint \"src/**/*.{js,ts,jsx,tsx,astro}\"",
|
||||
"lint:apply": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\" && eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\"",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"serve": "SITE_URL=http://localhost:3000 pnpm build && serve dist",
|
||||
"astro": "astro",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "pnpm lint:apply"
|
||||
"build": "astro build",
|
||||
"preview": "astro build && astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@playform/compress": "^0.2.0",
|
||||
"astro": "^5.13.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/mdx": "^4.3.6",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/mdx": "^4.3.12",
|
||||
"@astrojs/rss": "^4.0.14",
|
||||
"@astrojs/sitemap": "^3.6.0",
|
||||
"@eslint/js": "9.36.0",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@img/sharp-wasm32": "^0.34.4",
|
||||
"@types/jsonld": "^1.5.15",
|
||||
"@types/node": "^24.5.2",
|
||||
"@typescript-eslint/parser": "^8.44.1",
|
||||
"astro-capo": "^0.0.1",
|
||||
"astro-compress": "^2.3.8",
|
||||
"@fontsource/vt323": "^5.2.7",
|
||||
"@playform/compress": "^0.2.0",
|
||||
"astro": "^5.16.3",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"eslint": "9.36.0",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"husky": "^9.1.7",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"less": "^4.4.1",
|
||||
"lint-staged": "^16.2.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-config-standard": "^7.0.0",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"sass": "^1.93.1",
|
||||
"serve": "^14.2.5",
|
||||
"sharp": "^0.34.4",
|
||||
"sharp-ico": "^0.1.5",
|
||||
"tsx": "^4.20.5",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"@eslint/eslintrc": "3.3.1",
|
||||
"@pnpm/find-workspace-packages": "6.0.9",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.44.1"
|
||||
"canvas": "^3.2.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"packageManager": "pnpm@10.3.0+sha512.ee592eda8815a8a293c206bb0917c4bb0ff274c50def7cbc17be05ec641fc2d1b02490ce660061356bd0d126a4d7eb2ec8830e6959fb8a447571c631d5a2442d"
|
||||
"packageManager": "pnpm@10.18.0",
|
||||
"devDependencies": {
|
||||
"less": "^4.4.2",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
6763
pnpm-lock.yaml
generated
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- canvas
|
||||
- sharp
|
||||
@@ -1,19 +0,0 @@
|
||||
import { fileURLToPath } from 'url'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { writeFile, mkdir } from 'fs/promises'
|
||||
|
||||
import { compile } from 'json-schema-to-typescript'
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
const response = await fetch(
|
||||
'https://raw.githubusercontent.com/jsonresume/resume-schema/master/schema.json'
|
||||
)
|
||||
const schema = await response.json()
|
||||
|
||||
const types = await compile(schema, 'ResumeSchema', { bannerComment: '' })
|
||||
|
||||
const location = resolve(root, 'src/types/resume-schema.ts')
|
||||
console.log(`Writing to ${location}`)
|
||||
await mkdir(dirname(location), { recursive: true })
|
||||
await writeFile(location, types)
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getImage } from 'astro:assets'
|
||||
|
||||
import { data } from '@/data/data.js'
|
||||
import { data } from '~/data/data'
|
||||
|
||||
const imageSizes = [16, 32, 48, 64, 96, 128, 256, 512]
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
import { data } from '@/data/data'
|
||||
|
||||
const { basics } = data.profile
|
||||
---
|
||||
|
||||
<nav>
|
||||
<a href='/'>{basics.name}</a>
|
||||
<div>{basics.tagline}</div>
|
||||
</nav>
|
||||
|
||||
<style lang='less'>
|
||||
nav {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: var(--content-width);
|
||||
text-align: center;
|
||||
padding: var(--space-lg) var(--space-lg);
|
||||
}
|
||||
|
||||
a {
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: var(--font-md);
|
||||
color: var(--color-text-light);
|
||||
font-weight: 300;
|
||||
}
|
||||
</style>
|
||||
96
src/components/page/Header.astro
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
import { Picture } from "astro:assets";
|
||||
import { data } from "~/data/data";
|
||||
import { positionWithTeam } from "~/utils/utils.format";
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
const { Content, ...profile } = data.profile;
|
||||
const currentExperience = await data.experiences.getCurrent()
|
||||
const links = {
|
||||
'/': 'Posts',
|
||||
'/about': 'About',
|
||||
}
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<div class="image">
|
||||
<Picture
|
||||
class="picture"
|
||||
alt='Profile Picture'
|
||||
src={profile.image}
|
||||
fetchpriority="high"
|
||||
formats={['avif', 'webp', 'jpeg']}
|
||||
width={60}
|
||||
/>
|
||||
</div>
|
||||
<div class="info">
|
||||
<a class="name" href="/">{profile.name}</a>
|
||||
{currentExperience && (
|
||||
<a class="work" href="/">
|
||||
{currentExperience.data.position.name} @ {currentExperience.data.company.name}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div class="links">
|
||||
{Object.entries(links).map(([target, name]) => (
|
||||
<a class={currentPath === target ? 'link active' : 'link'} href={target}>{name}</a>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
max-width: var(--content-width);
|
||||
margin: 80px auto;
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
align-items: center;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
grid-template-rows: auto;
|
||||
grid-template-areas:
|
||||
"image info . links";
|
||||
}
|
||||
|
||||
img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.image {
|
||||
grid-area: image;
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.name {
|
||||
font-weight: var(--fw-md);
|
||||
color: var(--t-fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.work {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--t-fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
grid-area: links;
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
|
||||
.link {
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius);
|
||||
|
||||
&.active {
|
||||
background: var(--c-bg-em);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
src/components/page/Html.astro
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
import { icons } from '~/assets/images/images.icons';
|
||||
import Header from './Header.astro';
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string
|
||||
jsonLd?: unknown;
|
||||
image?: string;
|
||||
themeColor?: string;
|
||||
}
|
||||
|
||||
const { title, description, jsonLd, themeColor, image }= Astro.props;
|
||||
const schema = JSON.stringify(jsonLd)
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta name='HandheldFriendly' content='True' />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<link rel='sitemap' href='/sitemap-index.xml' />
|
||||
<link rel='manifest' href='/manifest.webmanifest' />
|
||||
{themeColor && <meta name='theme-color' content={themeColor} />}
|
||||
<link
|
||||
rel='alternate'
|
||||
type='application/rss+xml'
|
||||
title='RSS Feed'
|
||||
href='/articles/rss.xml'
|
||||
/>
|
||||
<meta name='description' content={description} />
|
||||
{image && <meta property='og:image' content={image} />}
|
||||
{
|
||||
jsonLd && (
|
||||
<script type='application/ld+json' is:inline set:html={schema} />
|
||||
)
|
||||
}
|
||||
{
|
||||
icons.pngs.map((icon) => (
|
||||
<link rel='icon' href={icon.src} type='image/png' sizes={icon.size} />
|
||||
))
|
||||
}
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
|
||||
@view-transition {
|
||||
navigation: auto; /* enabled! */
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
text-indent: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--c-bg-em: rgb(241, 242, 246);
|
||||
--c-line: #d3d3d3;
|
||||
--content-width: 800px;
|
||||
--gap: 16px;
|
||||
--system-ui: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
--t-fg: #222;
|
||||
--bg: #fff;
|
||||
--fw-df: 400;
|
||||
--fw-md: 600;
|
||||
--fs-sm: 14px;
|
||||
--fs-md: 16px;
|
||||
--radius: 10px;
|
||||
|
||||
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
|
||||
--font-family-heading: var(--font-family);
|
||||
--font-size: 15px;
|
||||
--letter-spacing: 0.5px;
|
||||
--color: #000;
|
||||
--background-color: #f1f5f9;
|
||||
--content-width: 1000px;
|
||||
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: 2rem;
|
||||
--space-xl: 3rem;
|
||||
--space-xxl: 4rem;
|
||||
|
||||
--font-xxl: 2rem;
|
||||
--font-xl: 1.5rem;
|
||||
--font-lg: 1.1rem;
|
||||
--font-sm: 0.875rem;
|
||||
--font-xs: 0.75rem;
|
||||
|
||||
--color-text-light: rgb(75, 85, 99);
|
||||
--color-border: #ddd;
|
||||
--color-link: #007bff;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--system-ui);
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-df);
|
||||
color: var(--t-fg);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--t-fg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p a {
|
||||
color: var(--t-fg);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--c-line);
|
||||
text-underline-offset: .35em;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--gap);
|
||||
}
|
||||
|
||||
[data-fadein] {
|
||||
transition: all 1s;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,14 +1,10 @@
|
||||
import { resolve } from 'path';
|
||||
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const base = 'content';
|
||||
|
||||
const articles = defineCollection({
|
||||
loader: glob({ pattern: '*/index.mdx', base: resolve(base, 'articles') }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
const posts = defineCollection({
|
||||
loader: glob({ pattern: "**/index.mdx", base: "./src/content/posts" }),
|
||||
schema: ({ image }) => z.object({
|
||||
slug: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
@@ -18,48 +14,39 @@ const articles = defineCollection({
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
heroImage: image(),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
const work = defineCollection({
|
||||
loader: glob({ pattern: '**/*.mdx', base: resolve(base, 'work') }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
const experiences = defineCollection({
|
||||
loader: glob({ pattern: "**/*.mdx", base: "./src/content/experiences" }),
|
||||
schema: ({ image }) => z.object({
|
||||
slug: z.string(),
|
||||
company: z.object({
|
||||
name: z.string(),
|
||||
position: z.string(),
|
||||
url: z.string().url().optional(),
|
||||
}),
|
||||
position: z.object({
|
||||
name: z.string(),
|
||||
team: z.string().optional(),
|
||||
}),
|
||||
summary: z.string().optional(),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
summary: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
logo: image().optional(),
|
||||
banner: image().optional(),
|
||||
stack: z.array(z.string()).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const references = defineCollection({
|
||||
loader: glob({ pattern: '*.md', base: resolve(base, 'references') }),
|
||||
schema: () =>
|
||||
z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
position: z.string(),
|
||||
company: z.string(),
|
||||
date: z.coerce.date(),
|
||||
relation: z.string(),
|
||||
profile: z.string(),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
const skills = defineCollection({
|
||||
loader: glob({ pattern: '*.mdx', base: resolve(base, 'skills') }),
|
||||
schema: () =>
|
||||
z.object({
|
||||
loader: glob({ pattern: "**/*.mdx", base: "./src/content/skills" }),
|
||||
schema: z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
technologies: z.array(z.string()),
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
export const collections = { articles, work, references, skills };
|
||||
const collections = { posts, experiences, skills };
|
||||
|
||||
export { collections };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
company:
|
||||
name: BilZonen
|
||||
position: Web Developer
|
||||
position:
|
||||
name: Web Developer
|
||||
startDate: 2010-06-01
|
||||
endDate: 2012-02-28
|
||||
summary: As a part-time web developer at bilzonen.dk, I managed both routine maintenance and major projects like new modules and integrations, introduced a custom provider-model system in .NET (C#) for data management, and established the development environment, including server setup and custom tools for building and testing.
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -1,6 +1,8 @@
|
||||
---
|
||||
company:
|
||||
name: Sampension
|
||||
position: Senior Frontend Developer
|
||||
position:
|
||||
name: Senior Frontend Developer
|
||||
startDate: 2018-01-01
|
||||
endDate: 2021-12-31
|
||||
logo: ./assets/logo.jpeg
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -1,6 +1,8 @@
|
||||
---
|
||||
company:
|
||||
name: Trendsales
|
||||
position: Web Developer
|
||||
position:
|
||||
name: Web Developer
|
||||
startDate: 2012-03-01
|
||||
endDate: 2012-09-30
|
||||
logo: ./assets/logo.png
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
company:
|
||||
name: Trendsales
|
||||
position: iOS and Android Developer
|
||||
position:
|
||||
name: iOS and Android Developer
|
||||
startDate: 2012-10-01
|
||||
endDate: 2015-12-31
|
||||
logo: ./trendsales-1/assets/logo.png
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
company:
|
||||
name: Trendsales
|
||||
position: Frontend Technical Lead
|
||||
position:
|
||||
name: Frontend Technical Lead
|
||||
startDate: 2016-01-01
|
||||
endDate: 2017-12-31
|
||||
logo: ./trendsales-1/assets/logo.png
|
||||
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 194 KiB |
@@ -1,6 +1,9 @@
|
||||
---
|
||||
company:
|
||||
name: ZeroNorth
|
||||
position: Senior Software Engineer @ Vessel Reporting Team
|
||||
position:
|
||||
name: Senior Software Engineer
|
||||
team: Vessel Reporting Team
|
||||
startDate: 2022-01-01
|
||||
endDate: 2023-05-01
|
||||
logo: ./assets/logo.png
|
||||
@@ -1,13 +1,16 @@
|
||||
|
||||
---
|
||||
company:
|
||||
name: ZeroNorth
|
||||
position: Senior Software Engineer @ Voyage Optimisation
|
||||
position:
|
||||
name: Senior Software Engineer
|
||||
team: Voyage Optimisation
|
||||
startDate: 2023-05-01
|
||||
endDate: 2025-03-01
|
||||
logo: ./zeronorth-1/assets/logo.png
|
||||
summary: "// TODO: describe my position in the Voyage Optimisation Team"
|
||||
slug: zeronorth-2
|
||||
stack:
|
||||
|
||||
- TypeScript
|
||||
- .NET
|
||||
- NodeJS
|
||||
@@ -18,6 +21,7 @@ stack:
|
||||
- Terraform
|
||||
- AWS
|
||||
- GitHub Actions
|
||||
|
||||
---
|
||||
|
||||
// TODO: describe my position in the Voyage Optimisation Team
|
||||
@@ -1,7 +1,9 @@
|
||||
|
||||
---
|
||||
company:
|
||||
name: ZeroNorth
|
||||
position: Senior Software Engineer @ AI Team
|
||||
position:
|
||||
name: Senior Software Engineer
|
||||
team: AI Team
|
||||
startDate: 2025-03-01
|
||||
logo: ./zeronorth-1/assets/logo.png
|
||||
summary: "# TODO: describe my role in our new AI team"
|
||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
@@ -1,3 +1,22 @@
|
||||
---
|
||||
name: Morten Olsen
|
||||
url: https://mortenolsen.pro
|
||||
location:
|
||||
city: Copenhagen
|
||||
countryCode: dk
|
||||
profiles:
|
||||
github:
|
||||
network:
|
||||
name: GitHub
|
||||
username: morten-olsen
|
||||
url: https://github.com/morten-olsen
|
||||
linkedin:
|
||||
network:
|
||||
name: LinkedIn
|
||||
username: mortenolsendk
|
||||
url: https://www.linkedin.com/in/mortenolsendk
|
||||
---
|
||||
|
||||
As a software engineer with a diverse skill set in frontend, backend, and DevOps, I find my greatest satisfaction in unraveling complex challenges and transforming them into achievable solutions. My career has predominantly been in frontend development, but my keen interest and adaptability have frequently drawn me into backend and DevOps roles. I am driven not by titles or hierarchy but by opportunities where I can make a real difference through my work.
|
||||
|
||||
In every role, I strive to blend my technical skills with a collaborative spirit, focusing on contributing to team goals and delivering practical, effective solutions. My passion for development extends beyond professional settings; I continually engage in personal projects to explore new technologies and methodologies, keeping my skills sharp and current.
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
class Articles {
|
||||
public find = () => getCollection('articles')
|
||||
public get = async (slug: string) => {
|
||||
const collection = await this.find()
|
||||
return collection.find((entry) => entry.data.slug === slug)
|
||||
}
|
||||
}
|
||||
|
||||
type Article = Exclude<Awaited<ReturnType<Articles['get']>>, undefined>
|
||||
|
||||
export { Articles, type Article }
|
||||
32
src/data/data.experiences.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getCollection, getEntry } from "astro:content";
|
||||
|
||||
class Experiences {
|
||||
public getAll = async () => {
|
||||
const collection = await getCollection('experiences');
|
||||
return collection.sort(
|
||||
(a, b) => new Date(b.data.startDate).getTime() - new Date(a.data.startDate).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
public get = async (id: string) => {
|
||||
const entry = await getEntry('experiences', id);
|
||||
if (!entry) {
|
||||
throw new Error(`Experience ${id} not found`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public getCurrent = async () => {
|
||||
const all = await this.getAll();
|
||||
return all.find((experience) => !experience.data.endDate);
|
||||
}
|
||||
|
||||
public getPrevious = async () => {
|
||||
const all = await this.getAll();
|
||||
return all.filter((experience) => experience.data.endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const experiences = new Experiences();
|
||||
|
||||
export { experiences }
|
||||
43
src/data/data.posts.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getCollection, getEntry, type CollectionEntry } from "astro:content";
|
||||
import { profile } from "./data.profile";
|
||||
|
||||
class Posts {
|
||||
#map = (post: CollectionEntry<'posts'>) => {
|
||||
return Object.assign(post, {
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.data.title,
|
||||
image: post.data.heroImage.src,
|
||||
datePublished: post.data.pubDate.toISOString(),
|
||||
keywords: post.data.tags,
|
||||
inLanguage: 'en-US',
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: profile.name,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public getPublished = async () => {
|
||||
const collection = await getCollection('posts');
|
||||
return collection
|
||||
.map(this.#map)
|
||||
.sort(
|
||||
(a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
public get = async (id: string) => {
|
||||
const entry = await getEntry('posts', id);
|
||||
if (!entry) {
|
||||
throw new Error(`Entry ${id} not found`)
|
||||
}
|
||||
return this.#map(entry);
|
||||
}
|
||||
}
|
||||
|
||||
const posts = new Posts();
|
||||
|
||||
export { posts }
|
||||
133
src/data/data.profile.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { z } from 'astro:content';
|
||||
import { frontmatter, Content } from '../content/profile/profile.md';
|
||||
import image from '../content/profile/profile.jpg';
|
||||
import type { ResumeSchema } from '~/types/resume-json';
|
||||
import { positionWithTeam } from '~/utils/utils.format';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
tagline: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
url: z.string(),
|
||||
contact: z.object({
|
||||
email: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
}).optional(),
|
||||
location: z.object({
|
||||
city: z.string(),
|
||||
countryCode: z.string(),
|
||||
}),
|
||||
profiles: z.record(z.string(), z.object({
|
||||
network: z.object({
|
||||
name: z.string(),
|
||||
}).optional(),
|
||||
username: z.string().optional(),
|
||||
url: z.string(),
|
||||
})),
|
||||
image: z.object({
|
||||
src: z.string(),
|
||||
format: z.enum(["png", "jpg", "jpeg", "tiff", "webp", "gif", "svg", "avif"]),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
const data = schema.parse({
|
||||
...frontmatter,
|
||||
image: image,
|
||||
})
|
||||
const profile = Object.assign(data, {
|
||||
Content,
|
||||
getJsonLd: async () => {
|
||||
const { experiences } = await import('./data.experiences');
|
||||
const currentExperience = await experiences.getCurrent();
|
||||
const previousExperiences = await experiences.getPrevious();
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
id: '#me',
|
||||
name: data.name,
|
||||
email: data.contact?.email,
|
||||
image: data.image.src,
|
||||
url: data.url,
|
||||
jobTitle: currentExperience?.data.position,
|
||||
contactPoint: Object.entries(data.profiles).map(([id, profile]) => ({
|
||||
'@type': 'ContactPoint',
|
||||
contactType: id,
|
||||
identifier: profile.username,
|
||||
url: profile.url
|
||||
})),
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: data.location.city,
|
||||
// addressRegion: data.profile.basics.location.region,
|
||||
addressCountry: data.location.countryCode
|
||||
},
|
||||
sameAs: Object.values(data.profiles),
|
||||
hasOccupation: currentExperience && {
|
||||
'@type': 'EmployeeRole',
|
||||
roleName: currentExperience.data.position,
|
||||
startDate: currentExperience.data.startDate.toISOString()
|
||||
},
|
||||
worksFor: currentExperience && {
|
||||
'@type': 'Organization',
|
||||
name: currentExperience?.data.company.name,
|
||||
sameAs: currentExperience?.data.company.url
|
||||
},
|
||||
alumniOf: previousExperiences.map((w) => ({
|
||||
'@type': 'Organization',
|
||||
name: w.data.company.name,
|
||||
sameAs: w.data.company.url,
|
||||
employee: {
|
||||
'@type': 'Person',
|
||||
hasOccupation: {
|
||||
'@type': 'EmployeeRole',
|
||||
roleName: positionWithTeam(w.data.position.name, w.data.position.team),
|
||||
startDate: w.data.startDate.toISOString(),
|
||||
endDate: w.data.endDate?.toISOString()
|
||||
},
|
||||
sameAs: '#me'
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
getResumeJson: async (): Promise<ResumeSchema> => {
|
||||
const { experiences } = await import('./data.experiences');
|
||||
const { skills } = await import('./data.skills');
|
||||
const allExperiences = await experiences.getAll();
|
||||
const allSkills = await skills.getAll();
|
||||
return {
|
||||
basics: {
|
||||
name: data.name,
|
||||
label: data.role,
|
||||
image: data.image.src,
|
||||
email: data.contact?.email,
|
||||
phone: data.contact?.phone,
|
||||
url: data.url,
|
||||
location: data.location && {
|
||||
city: data.location.city,
|
||||
countryCode: data.location.countryCode,
|
||||
},
|
||||
profiles: Object.entries(data.profiles || {}).map(([id, profile]) => ({
|
||||
network: profile.network?.name || id,
|
||||
username: profile.username,
|
||||
url: profile.url,
|
||||
}))
|
||||
},
|
||||
work: allExperiences.map((experience) => ({
|
||||
name: experience.data.company.name,
|
||||
position: positionWithTeam(experience.data.position.name, experience.data.position.team),
|
||||
url: experience.data.company.url,
|
||||
startDate: experience.data.startDate.toISOString(),
|
||||
endDate: experience.data.endDate?.toISOString(),
|
||||
})),
|
||||
skills: allSkills.map((skill) => ({
|
||||
name: skill.data.name,
|
||||
keywords: skill.data.technologies,
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export { profile };
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
class References {
|
||||
public find = () => getCollection('references')
|
||||
public get = async (slug: string) => {
|
||||
const collection = await this.find()
|
||||
return collection.find((entry) => entry.data.slug === slug)
|
||||
}
|
||||
}
|
||||
|
||||
type Reference = Exclude<Awaited<ReturnType<References['get']>>, undefined>
|
||||
|
||||
export { References, type Reference }
|
||||
@@ -1,5 +0,0 @@
|
||||
const site = {
|
||||
theme: '#30E130'
|
||||
}
|
||||
|
||||
export { site }
|
||||
@@ -1,13 +1,20 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
import { getCollection, getEntry } from "astro:content";
|
||||
|
||||
class Skills {
|
||||
public find = () => getCollection('skills')
|
||||
public get = async (slug: string) => {
|
||||
const collection = await this.find()
|
||||
return collection.find((entry) => entry.data.slug === slug)
|
||||
public getAll = async () => {
|
||||
const collection = await getCollection('skills');
|
||||
return collection;
|
||||
}
|
||||
|
||||
public get = async (id: string) => {
|
||||
const entry = await getEntry('skills', id);
|
||||
if (!entry) {
|
||||
throw new Error(`Could not find skill ${id}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
type Skill = Exclude<Awaited<ReturnType<Skills['get']>>, undefined>
|
||||
const skills = new Skills();
|
||||
|
||||
export { Skills, type Skill }
|
||||
export { skills }
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
import { profile } from '../../content/profile/profile.js';
|
||||
import { posts } from './data.posts';
|
||||
import { experiences } from './data.experiences';
|
||||
import { profile } from './data.profile';
|
||||
import { skills } from './data.skills';
|
||||
|
||||
import { type Article, Articles } from './data.articles.js';
|
||||
import { References } from './data.references.ts';
|
||||
import { site } from './data.site.ts';
|
||||
import { Skills } from './data.skills.ts';
|
||||
import { getJsonLDResume, getJsonResume } from './data.utils.js';
|
||||
import { Work, type WorkItem } from './data.work.js';
|
||||
const data = { posts, experiences, profile, skills };
|
||||
|
||||
class Data {
|
||||
public articles = new Articles();
|
||||
public work = new Work();
|
||||
public references = new References();
|
||||
public skills = new Skills();
|
||||
public profile = profile;
|
||||
public site = site;
|
||||
|
||||
public getJsonResume = getJsonResume.bind(null, this);
|
||||
public getJsonLDResume = getJsonLDResume.bind(null, this);
|
||||
}
|
||||
|
||||
const data = new Data();
|
||||
|
||||
type Profile = typeof profile;
|
||||
export type { Article, Profile, WorkItem };
|
||||
export { data, Data };
|
||||
export { data };
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { Article, Data } from './data'
|
||||
|
||||
import type { ResumeSchema } from '@/types/resume-schema.js'
|
||||
|
||||
|
||||
const getJsonResume = async (data: Data) => {
|
||||
const profile = data.profile
|
||||
const resume = {
|
||||
basics: profile.basics
|
||||
} satisfies ResumeSchema
|
||||
|
||||
return resume
|
||||
}
|
||||
|
||||
const getArticleJsonLD = async (data: Data, article: Article) => {
|
||||
const jsonld = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: article.data.title,
|
||||
image: article.data.heroImage.src,
|
||||
datePublished: article.data.pubDate.toISOString(),
|
||||
keywords: article.data.tags,
|
||||
inLanguage: 'en-US',
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: data.profile.basics.name,
|
||||
url: data.profile.basics.url
|
||||
}
|
||||
}
|
||||
return jsonld
|
||||
}
|
||||
|
||||
const getJsonLDResume = async (data: Data) => {
|
||||
const work = await data.work.find()
|
||||
const currentWork = work.find((w) => !w.data.endDate)
|
||||
const otherWork = work.filter((w) => w !== currentWork)
|
||||
|
||||
const jsonld = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
id: '#me',
|
||||
name: data.profile.basics.name,
|
||||
email: data.profile.basics.email,
|
||||
image: data.profile.basics.image,
|
||||
url: data.profile.basics.url,
|
||||
jobTitle: currentWork?.data.position,
|
||||
contactPoint: data.profile.basics.profiles.map((profile) => ({
|
||||
'@type': 'ContactPoint',
|
||||
contactType: profile.network.toLowerCase(),
|
||||
identifier: profile.username,
|
||||
url: profile.url
|
||||
})),
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: data.profile.basics.location.city,
|
||||
addressRegion: data.profile.basics.location.region,
|
||||
addressCountry: data.profile.basics.location.countryCode
|
||||
},
|
||||
sameAs: data.profile.basics.profiles.map((profile) => profile.url),
|
||||
hasOccupation: currentWork && {
|
||||
'@type': 'EmployeeRole',
|
||||
roleName: currentWork.data.position,
|
||||
startDate: currentWork.data.startDate.toISOString()
|
||||
},
|
||||
worksFor: currentWork && {
|
||||
'@type': 'Organization',
|
||||
name: currentWork?.data.name,
|
||||
sameAs: currentWork?.data.url
|
||||
},
|
||||
alumniOf: otherWork.map((w) => ({
|
||||
'@type': 'Organization',
|
||||
name: w.data.name,
|
||||
sameAs: w.data.url,
|
||||
employee: {
|
||||
'@type': 'Person',
|
||||
hasOccupation: {
|
||||
'@type': 'EmployeeRole',
|
||||
roleName: w.data.position,
|
||||
startDate: w.data.startDate.toISOString(),
|
||||
endDate: w.data.endDate?.toISOString()
|
||||
},
|
||||
sameAs: '#me'
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return jsonld
|
||||
}
|
||||
|
||||
export { getJsonResume, getJsonLDResume, getArticleJsonLD }
|
||||
@@ -1,12 +0,0 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
class Work {
|
||||
public find = () => getCollection('work')
|
||||
public get = async (slug: string) => {
|
||||
const collection = await this.find()
|
||||
return collection.find((entry) => entry.data.slug === slug)
|
||||
}
|
||||
}
|
||||
|
||||
type WorkItem = Exclude<Awaited<ReturnType<Work['get']>>, undefined>
|
||||
export { Work, type WorkItem }
|
||||
2
src/env.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
import type { Article } from '@/data/data.js'
|
||||
import { range } from '@/utils/data'
|
||||
|
||||
import Html from '../html/html.astro'
|
||||
|
||||
type Props = {
|
||||
pageNumber: number
|
||||
pageCount: number
|
||||
articles: Article[]
|
||||
}
|
||||
const { articles, pageNumber, pageCount } = Astro.props
|
||||
const hasPrev = pageNumber > 1
|
||||
const hasNext = pageNumber < pageCount
|
||||
---
|
||||
|
||||
<Html title='Articles' description='A list of articles'>
|
||||
<h1>Articles</h1>
|
||||
{
|
||||
articles.map((article) => (
|
||||
<div>
|
||||
<h2>{article.data.title}</h2>
|
||||
<p>{article.data.description}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<nav>
|
||||
<a aria-disabled={!hasPrev} href={`/articles/pages/${pageNumber - 1}`}
|
||||
>Previous</a
|
||||
>
|
||||
{
|
||||
range(1, pageCount).map((page) => (
|
||||
<a
|
||||
class:list={[page === pageNumber ? 'active' : undefined]}
|
||||
href={`/articles/pages/${page}`}
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<a aria-disabled={!hasNext} href={`/articles/pages/${pageNumber + 1}`}>
|
||||
Next
|
||||
</a>
|
||||
</nav>
|
||||
</Html>
|
||||
|
||||
<style lang='less'>
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a[aria-disabled='true'] {
|
||||
color: #ccc;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
import { data } from '@/data/data.js'
|
||||
|
||||
import Article from './articles.item.astro'
|
||||
|
||||
type Props = {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { class: className, ...rest } = Astro.props
|
||||
const articleCount = 6
|
||||
const allArticles = await data.articles.find()
|
||||
const sortedArticles = allArticles.sort(
|
||||
(a, b) =>
|
||||
new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
|
||||
)
|
||||
const hasMore = sortedArticles.length > articleCount
|
||||
const articles = sortedArticles.slice(0, articleCount)
|
||||
---
|
||||
|
||||
<div class:list={['articles', className]} {...rest}>
|
||||
<h2>Articles</h2>
|
||||
<div class='items'>
|
||||
{articles.map((article) => <Article article={article} />)}
|
||||
</div>
|
||||
{hasMore && <a href='/articles/pages/1'>View all articles</a>}
|
||||
</div>
|
||||
|
||||
<style lang='less'>
|
||||
.articles {
|
||||
display: grid;
|
||||
gap: var(--space-lg);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.articles {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
|
||||
import Time from '@/components/time/absolute.astro'
|
||||
import type { Article } from '@/data/data.js'
|
||||
import { formatDate } from '@/utils/time.js'
|
||||
|
||||
type Props = {
|
||||
article: Article
|
||||
}
|
||||
|
||||
const { article: item } = Astro.props
|
||||
---
|
||||
|
||||
<a href={`/articles/${item.data.slug}`}>
|
||||
<article>
|
||||
<Picture
|
||||
class='thumb'
|
||||
alt='thumbnail image'
|
||||
src={item.data.heroImage}
|
||||
formats={['avif', 'webp', 'jpeg']}
|
||||
width={100}
|
||||
/>
|
||||
<div class='content'>
|
||||
<small>
|
||||
<Time format={formatDate} datetime={item.data.pubDate} />
|
||||
</small>
|
||||
<h3>{item.data.title}</h3>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
|
||||
<style lang='less'>
|
||||
a {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
a {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
article {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
border-radius: 0.5rem;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
grid-area: image;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
small {
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
|
||||
import { data } from '@/data/data.js'
|
||||
|
||||
import Profile from './description.profile.astro'
|
||||
|
||||
type Props = {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { class: className, ...rest } = Astro.props
|
||||
const { Content, basics, image } = data.profile
|
||||
---
|
||||
|
||||
<div class:list={['main', className]} {...rest}>
|
||||
<Picture
|
||||
class='picture'
|
||||
alt='Profile Picture'
|
||||
src={image}
|
||||
formats={['avif', 'webp', 'jpeg']}
|
||||
width={230}
|
||||
/>
|
||||
<h1>{basics.name}</h1>
|
||||
<h2>{basics.tagline}</h2>
|
||||
<div class='description'>
|
||||
<Content />
|
||||
</div>
|
||||
<div class='profiles'>
|
||||
{basics.profiles.map((profile) => <Profile profile={profile} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang='less'>
|
||||
@media screen and (max-width: 768px) {
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
line-height: 1.3rem;
|
||||
text-align: justify;
|
||||
|
||||
:global(p) {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-xxl);
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: normal;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-light);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.picture {
|
||||
border-radius: 0 0 50% 0;
|
||||
width: 230px;
|
||||
height: 230px;
|
||||
clip-path: circle(43%);
|
||||
shape-outside: border-box;
|
||||
float: left;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.profiles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import type { Profile } from '@/data/data'
|
||||
|
||||
type Props = {
|
||||
profile: Profile['basics']['profiles'][number]
|
||||
}
|
||||
const { profile } = Astro.props
|
||||
---
|
||||
|
||||
<a href={profile.url} target='_blank'>
|
||||
<Icon class='icon' name={profile.icon} />
|
||||
<div class='network'>{profile.network}</div>
|
||||
<div class='username'>{profile.username}</div>
|
||||
</a>
|
||||
|
||||
<style lang='less'>
|
||||
a {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
column-gap: var(--space-sm);
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas: 'icon network' 'icon username';
|
||||
}
|
||||
|
||||
.icon {
|
||||
grid-area: icon;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.network {
|
||||
grid-area: network;
|
||||
}
|
||||
|
||||
.username {
|
||||
grid-area: username;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -1,114 +0,0 @@
|
||||
---
|
||||
import { data } from '@/data/data'
|
||||
|
||||
import Html from '../html/html.astro'
|
||||
import Articles from './articles/articles.astro'
|
||||
import Description from './description/description.astro'
|
||||
import Info from './info/info.astro'
|
||||
import Skills from './skills/skills.astro'
|
||||
import Work from './work/work.astro'
|
||||
|
||||
const jsonLd = await data.getJsonLDResume()
|
||||
---
|
||||
|
||||
<Html
|
||||
title={data.profile.basics.name}
|
||||
description='Landing page'
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<div class='wrapper'>
|
||||
<div class='frontpage'>
|
||||
<Description class='description' />
|
||||
<Info class='info' />
|
||||
<Articles class='articles' />
|
||||
<Skills class='skills' />
|
||||
<Work class='work' />
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
<style lang='less'>
|
||||
.wrapper {
|
||||
--gap: var(--space-xxl);
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: var(--content-width);
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
.frontpage {
|
||||
display: grid;
|
||||
gap: var(--gap);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: auto;
|
||||
overflow: hidden;
|
||||
grid-template-areas:
|
||||
'info description description description'
|
||||
'articles articles articles articles'
|
||||
'skills work work work';
|
||||
}
|
||||
|
||||
.frontpage > * {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 0.6px;
|
||||
background-color: var(--color-border);
|
||||
position: absolute;
|
||||
bottom: calc(var(--gap) * -0.5);
|
||||
left: calc(var(--gap) * -0.5);
|
||||
right: calc(var(--gap) * -0.5);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0.6px;
|
||||
background-color: var(--color-border);
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
top: calc(var(--gap) * -0.5);
|
||||
bottom: calc(var(--gap) * -0.5);
|
||||
right: calc(var(--gap) * -0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
grid-area: info;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.articles {
|
||||
grid-area: articles;
|
||||
}
|
||||
|
||||
.skills {
|
||||
grid-area: skills;
|
||||
}
|
||||
|
||||
.work {
|
||||
grid-area: work;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
--gap: var(--space-lg);
|
||||
}
|
||||
.frontpage {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'description'
|
||||
'info'
|
||||
'articles'
|
||||
'skills'
|
||||
'work';
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
import { data } from '@/data/data.js'
|
||||
|
||||
type Props = {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { class: className, ...rest } = Astro.props
|
||||
const { basics } = data.profile
|
||||
---
|
||||
|
||||
<div class:list={['sidebar', className]} {...rest}>
|
||||
<div>
|
||||
<div><b>🌐 Location</b></div>
|
||||
<div>{basics.location.city} {basics.location.countryCode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b>✉️ Email</b></div>
|
||||
<div><a href={`mailto:${basics.email}`}>{basics.email}</a></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b>🕸️ Website</b></div>
|
||||
<div><a href={basics.url}>{basics.url}</a></div>
|
||||
</div>
|
||||
<div>
|
||||
<div><b>🙊 Lanuages</b></div>
|
||||
<div>{basics.languages.map((l) => l.name).join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang='less'>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
gap: var(--space-md);
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media print {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
import { data } from '@/data/data.js'
|
||||
|
||||
type Props = {
|
||||
class?: string
|
||||
}
|
||||
|
||||
const { class: className, ...rest } = Astro.props
|
||||
const skills = await data.skills.find()
|
||||
---
|
||||
|
||||
<div class:list={['skills', className]} {...rest}>
|
||||
<h2>Skills</h2>
|
||||
<div class='skill'>
|
||||
{
|
||||
skills.map((item) => (
|
||||
<div class='item'>
|
||||
<h3>{item.data.name}</h3>
|
||||
<ul>
|
||||
{item.data.technologies.map((tech) => (
|
||||
<li>{tech}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang='less'>
|
||||
h2 {
|
||||
font-size: var(--font-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.skill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.item {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-right: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -1,134 +0,0 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets';
|
||||
import Time from '@/components/time/absolute.astro';
|
||||
import { formatYearMonth } from '@/utils/time.js';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
type Props = {
|
||||
class?: string;
|
||||
};
|
||||
|
||||
const { class: className, ...rest } = Astro.props;
|
||||
const allWork = await getCollection('work');
|
||||
const work = allWork.sort((a, b) => {
|
||||
return new Date(b.data.startDate).getTime() - new Date(a.data.startDate).getTime();
|
||||
});
|
||||
---
|
||||
|
||||
<div class:list={[className]} {...rest}>
|
||||
<h2>Work</h2>
|
||||
<div class="list">
|
||||
{
|
||||
work.map((item) => (
|
||||
<div class="item">
|
||||
<div class="time">
|
||||
<Time format={formatYearMonth} datetime={item.data.endDate} />
|
||||
<br />
|
||||
<Time format={formatYearMonth} datetime={item.data.startDate} />
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
{item.data.logo && <Picture class="logo" src={item.data.logo} alt={item.data.name} />}
|
||||
<div class="info">
|
||||
<h3>{item.data.position}</h3>
|
||||
<h4>{item.data.name}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<p>{item.data.summary}</p>
|
||||
{item.data.stack && (
|
||||
<div class="stack">
|
||||
{item.data.stack.map((item) => (
|
||||
<div>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<a href="/work-history">See full work history</a>
|
||||
</div>
|
||||
|
||||
<style lang="less">
|
||||
h2 {
|
||||
font-size: var(--font-xl);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--font-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-xl);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.time {
|
||||
grid-column: 1;
|
||||
font-size: var(--font-xs);
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
break-inside: avoid;
|
||||
grid-column: 2 / 5;
|
||||
border: 0.6px solid var(--color-border);
|
||||
padding: var(--space-md);
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--font-xs);
|
||||
|
||||
div {
|
||||
margin-right: var(--space-xxs);
|
||||
margin-bottom: var(--space-xxs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
import '@/style/theme.css'
|
||||
|
||||
import { Head } from 'astro-capo'
|
||||
|
||||
import { icons } from '@/assets/images/icons.js'
|
||||
import { data } from '@/data/data.js'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
description: string
|
||||
image?: string
|
||||
jsonLd?: unknown
|
||||
}
|
||||
const { props } = Astro
|
||||
const schema = JSON.stringify(props.jsonLd)
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
<Head>
|
||||
<meta charset='UTF-8' />
|
||||
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
|
||||
<meta name='HandheldFriendly' content='True' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||
<title>{props.title}</title>
|
||||
<link rel='sitemap' href='/sitemap-index.xml' />
|
||||
<link rel='manifest' href='/manifest.webmanifest' />
|
||||
<meta name='generator' content={Astro.generator} />
|
||||
<meta name='theme-color' content={data.site.theme} />
|
||||
<link
|
||||
rel='alternate'
|
||||
type='application/rss+xml'
|
||||
title='RSS Feed'
|
||||
href='/articles/rss.xml'
|
||||
/>
|
||||
{
|
||||
props.description && (
|
||||
<meta name='description' content={props.description} />
|
||||
)
|
||||
}
|
||||
{props.image && <meta property='og:image' content={props.image} />}
|
||||
{
|
||||
props.jsonLd && (
|
||||
<script type='application/ld+json' is:inline set:html={schema} />
|
||||
)
|
||||
}
|
||||
{
|
||||
icons.pngs.map((icon) => (
|
||||
<link rel='icon' href={icon.src} type='image/png' sizes={icon.size} />
|
||||
))
|
||||
}
|
||||
</Head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import Header from '@/components/header/header.astro'
|
||||
import { data } from '@/data/data.js'
|
||||
|
||||
import Html from '../html/html.astro'
|
||||
import Item from './work-history.item.astro'
|
||||
|
||||
const allWork = await data.work.find()
|
||||
const work = allWork.sort(
|
||||
(a, b) => b.data.startDate.getTime() - a.data.startDate.getTime()
|
||||
)
|
||||
---
|
||||
|
||||
<Html title='Work' description='A list of work experiences'>
|
||||
<Header />
|
||||
<div class='wrapper'>
|
||||
<h2>Work history</h2>
|
||||
<div class='list'>
|
||||
{work.map((item) => <Item item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
|
||||
<style lang='less'>
|
||||
.wrapper {
|
||||
margin: 0 auto;
|
||||
max-width: var(--content-width);
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: var(--space-xl);
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
justify-content: self-end;
|
||||
align-items: self-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
import { render } from 'astro:content';
|
||||
import Time from '@/components/time/absolute.astro'
|
||||
import type { WorkItem } from '@/data/data.js'
|
||||
import { formatYearMonth } from '@/utils/time.js'
|
||||
|
||||
type Props = {
|
||||
item: WorkItem
|
||||
}
|
||||
|
||||
const { item } = Astro.props
|
||||
const { Content } = await render(item)
|
||||
---
|
||||
|
||||
<div class='item'>
|
||||
<div class='time'>
|
||||
<Time format={formatYearMonth} datetime={item.data.endDate} />
|
||||
-
|
||||
<Time format={formatYearMonth} datetime={item.data.startDate} />
|
||||
</div>
|
||||
<div class='main'>
|
||||
<h3>{item.data.position}</h3>
|
||||
<h4>{item.data.name}</h4>
|
||||
<div class='content'>
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang='less'>
|
||||
.item {
|
||||
display: contents;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.main {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.content {
|
||||
line-height: 1.5;
|
||||
letter-spacing: 1px;
|
||||
|
||||
ul {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
</style>
|
||||
226
src/pages/about.astro
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
import { Picture } from "astro:assets";
|
||||
import Html from "~/components/page/Html.astro";
|
||||
import { data } from "~/data/data"
|
||||
import { formatMonthYear, positionWithTeam } from "~/utils/utils.format";
|
||||
|
||||
const { Content, ...profile } = data.profile;
|
||||
const experiences = await data.experiences.getAll();
|
||||
const skills = await data.skills.getAll();
|
||||
const jsonLd = await profile.getJsonLd();
|
||||
---
|
||||
|
||||
<Html
|
||||
title={profile.name}
|
||||
description={profile.name}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<div class="profiles">
|
||||
<h2>Profiles</h2>
|
||||
<ul>
|
||||
{Object.entries(profile.profiles).map(([id, profile]) => (
|
||||
<li class="profile">
|
||||
<a href={profile.url} target="_blank">{profile.network?.name || id}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="experiences">
|
||||
<h2>Experiences</h2>
|
||||
<div class="content">
|
||||
{experiences.map((experience) => (
|
||||
<div data-fadein class="experience">
|
||||
{experience.data.logo && (
|
||||
<div class="logo">
|
||||
<Picture
|
||||
alt='thumbnail image'
|
||||
fetchpriority="auto"
|
||||
src={experience.data.logo}
|
||||
formats={['avif', 'webp', 'jpeg']}
|
||||
width={50}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div class="header">
|
||||
<a class="companyName" href={`/experiences/${experience.id}`}><h3>{experience.data.company.name}</h3></a>
|
||||
<a class="position" href={`/experiences/${experience.id}`}><h3>{positionWithTeam(experience.data.position.name, experience.data.position.team)}</h3></a>
|
||||
</div>
|
||||
<div class="time">
|
||||
<div class="from">
|
||||
{formatMonthYear(experience.data.startDate)}
|
||||
</div>
|
||||
<div class="to">
|
||||
{formatMonthYear(experience.data.endDate)}
|
||||
</div>
|
||||
</div>
|
||||
{experience.data.stack && (
|
||||
<div class="stack">
|
||||
<ul>
|
||||
{experience.data.stack.map((item) => (
|
||||
<li>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skills">
|
||||
<h2>Technologies</h2>
|
||||
{skills.map((skill) => (
|
||||
<div class="skill" data-fadein>
|
||||
<a class="name" href={`/skills/${skill.id}`}><h3>{skill.data.name}</h3></a>
|
||||
<div class="technologies">
|
||||
<ul>
|
||||
{skill.data.technologies.map((technology) => (
|
||||
<li>{technology}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</Html>
|
||||
|
||||
<script src="../scripts/fadein.ts"></script>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
max-width: var(--content-width);
|
||||
margin: auto;
|
||||
display: grid;
|
||||
gap: calc(var(--gap) * 3);
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-rows: auto;
|
||||
grid-template-areas:
|
||||
"content content"
|
||||
"profiles profiles"
|
||||
"skills experiences";
|
||||
|
||||
h2 {
|
||||
font-weight: var(--fw-md);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
}
|
||||
|
||||
.profiles {
|
||||
grid-area: profiles;
|
||||
display: flex;
|
||||
gap: var(--gap);
|
||||
|
||||
ul {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
.skills {
|
||||
grid-area: skills;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
|
||||
.skill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap);
|
||||
|
||||
.technologies ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--gap) / 2);
|
||||
|
||||
li {
|
||||
border: solid var(--c-line) 1px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.experiences {
|
||||
grid-area: experiences;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--gap) * 2);
|
||||
}
|
||||
|
||||
.experience {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto;
|
||||
column-gap: var(--gap);
|
||||
row-gap: calc(var(--gap) / 2);
|
||||
align-items: center;
|
||||
grid-template-areas:
|
||||
"logo header time"
|
||||
"logo stack time";
|
||||
|
||||
.logo {
|
||||
grid-area: logo;
|
||||
|
||||
img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
grid-area: time;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.companyName {
|
||||
grid-area: companyName;
|
||||
font-weight: var(--fw-md);
|
||||
}
|
||||
|
||||
.position {
|
||||
grid-area: position;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.summary{
|
||||
grid-area: summary;
|
||||
}
|
||||
|
||||
.stack {
|
||||
grid-area: stack;
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc(var(--gap) / 4);
|
||||
|
||||
li {
|
||||
border: solid var(--c-line) 1px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
import { type Article, data } from '@/data/data.js'
|
||||
import ArticleView from '@/layouts/article/article.astro'
|
||||
|
||||
type Props = {
|
||||
article: Article
|
||||
}
|
||||
|
||||
export const getStaticPaths = async () => {
|
||||
const articles = await data.articles.find()
|
||||
return articles.map((article) => ({
|
||||
params: { slug: article.data.slug },
|
||||
props: { article }
|
||||
}))
|
||||
}
|
||||
const { props } = Astro
|
||||
const { article } = props
|
||||
---
|
||||
|
||||
<ArticleView article={article} />
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
import { type Article, data } from '@/data/data.js'
|
||||
import Articles from '@/layouts/articles/articles.astro'
|
||||
import { range } from '@/utils/data.js'
|
||||
|
||||
type Props = {
|
||||
articles: Article[]
|
||||
pageNumber: number
|
||||
pageCount: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const pageSize = 2
|
||||
const allArticles = await data.articles.find()
|
||||
const pageCount = Math.ceil(allArticles.length / pageSize)
|
||||
const pages = range(0, pageCount).map((index) => {
|
||||
const start = index * pageSize
|
||||
const end = start + pageSize
|
||||
return {
|
||||
pageNumber: index + 1,
|
||||
pageCount,
|
||||
pageSize,
|
||||
articles: allArticles.slice(start, end)
|
||||
}
|
||||
})
|
||||
return pages.map((page) => ({
|
||||
params: { page: String(page.pageNumber) },
|
||||
props: page
|
||||
}))
|
||||
}
|
||||
|
||||
const { props } = Astro
|
||||
---
|
||||
|
||||
<Articles {...props} />
|
||||
@@ -1,18 +0,0 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
import { data } from '@/data/data.ts';
|
||||
|
||||
export async function GET(context: APIContext) {
|
||||
const articles = await data.articles.find();
|
||||
const profile = data.profile;
|
||||
return rss({
|
||||
title: profile.basics.name,
|
||||
description: profile.basics.tagline,
|
||||
site: context.site || 'http://localhost:3000',
|
||||
items: articles.map((article) => ({
|
||||
...article.data,
|
||||
link: `/articles/${article.data.slug}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||