This commit is contained in:
Morten Olsen
2025-12-02 22:38:20 +01:00
parent 1693a2620c
commit 06564dff21
117 changed files with 2522 additions and 7180 deletions

View File

@@ -1,3 +0,0 @@
/node_modules/
/.astro/
/.vscode/

View File

@@ -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
View File

@@ -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/

View File

@@ -1 +0,0 @@
pnpm lint-staged

View File

@@ -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'
}
}
]
}

View File

@@ -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
}

View File

@@ -1,14 +0,0 @@
{
"values": {
"monoRepo": false
},
"entries": [
{
"timestamp": "2025-09-23T20:22:40.143Z",
"template": "eslint",
"values": {
"monoRepo": false
}
}
]
}

View File

@@ -1,4 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode", "esbenp.prettier-vscode"],
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

14
.vscode/settings.json vendored
View File

@@ -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"
}
}

View File

@@ -1,47 +0,0 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](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).

View File

@@ -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
}
}
})

View File

@@ -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 };

View File

@@ -1,10 +0,0 @@
---
title: Bob the algorithm
link: /articles/bob-the-algorithm
keywords:
- Typescript
- React Native
- Algorithmic
---
`// TODO`

View File

@@ -1,9 +0,0 @@
---
title: Bob the algorithm
link: https://github.com/morten-olsen/mini-loader
keywords:
- Typescript
- Task management
---
`// TODO`

View File

@@ -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"
]
}
}
}

View File

@@ -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"
}
}
}
}
}

View File

@@ -1,11 +0,0 @@
version: '3'
services:
dev:
build:
context: ./docker
working_dir: /app
volumes:
- ./:/app
ports:
- 4321:4321
command: [ pnpm, dev, '--host' ]

View File

@@ -1,3 +0,0 @@
FROM node:20-alpine
RUN corepack enable
USER 1000

View File

@@ -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/'],
},
);

1
notes.md Normal file
View File

@@ -0,0 +1 @@
https://www.arisacoba.com/

View File

@@ -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

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- canvas
- sharp

View File

@@ -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)

View File

@@ -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]

View File

@@ -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>

View 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>

View 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>

View File

@@ -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 };

View File

@@ -1,6 +1,8 @@
---
name: BilZonen
position: Web Developer
company:
name: BilZonen
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.

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,6 +1,8 @@
---
name: Sampension
position: Senior Frontend Developer
company:
name: Sampension
position:
name: Senior Frontend Developer
startDate: 2018-01-01
endDate: 2021-12-31
logo: ./assets/logo.jpeg

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,6 +1,8 @@
---
name: Trendsales
position: Web Developer
company:
name: Trendsales
position:
name: Web Developer
startDate: 2012-03-01
endDate: 2012-09-30
logo: ./assets/logo.png

View File

@@ -1,6 +1,8 @@
---
name: Trendsales
position: iOS and Android Developer
company:
name: Trendsales
position:
name: iOS and Android Developer
startDate: 2012-10-01
endDate: 2015-12-31
logo: ./trendsales-1/assets/logo.png

View File

@@ -1,6 +1,8 @@
---
name: Trendsales
position: Frontend Technical Lead
company:
name: Trendsales
position:
name: Frontend Technical Lead
startDate: 2016-01-01
endDate: 2017-12-31
logo: ./trendsales-1/assets/logo.png

View File

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

View File

@@ -1,6 +1,9 @@
---
name: ZeroNorth
position: Senior Software Engineer @ Vessel Reporting Team
company:
name: ZeroNorth
position:
name: Senior Software Engineer
team: Vessel Reporting Team
startDate: 2022-01-01
endDate: 2023-05-01
logo: ./assets/logo.png

View File

@@ -1,13 +1,16 @@
---
name: ZeroNorth
position: Senior Software Engineer @ Voyage Optimisation
company:
name: ZeroNorth
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

View File

@@ -1,7 +1,9 @@
---
name: ZeroNorth
position: Senior Software Engineer @ AI Team
company:
name: ZeroNorth
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"

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -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.

View File

@@ -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 }

View 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
View 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
View 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 };

View File

@@ -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 }

View File

@@ -1,5 +0,0 @@
const site = {
theme: '#30E130'
}
export { site }

View File

@@ -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 }

View File

@@ -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 };

View File

@@ -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 }

View File

@@ -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
View File

@@ -1,2 +0,0 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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>

View File

@@ -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} />

View File

@@ -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} />

View File

@@ -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}/`,
})),
});
}

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