First commit for universe
This commit is contained in:
parent
e4e6610fee
commit
bfd03cda87
26 changed files with 900 additions and 11 deletions
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Start development server:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Build for production:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Type checking and linting:**
|
||||
|
||||
```bash
|
||||
npm run check # Type check with svelte-check
|
||||
npm run lint # Check formatting and linting
|
||||
npm run format # Auto-format code with prettier
|
||||
```
|
||||
|
||||
**Preview production build:**
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a SvelteKit personal portfolio site for @jedmund that integrates with multiple external APIs to display real-time data about music listening habits and gaming activity.
|
||||
|
||||
### Key Architecture Components
|
||||
|
||||
**API Integration Layer** (`src/routes/api/`)
|
||||
|
||||
- **Redis caching**: Shared Redis client (`redis-client.ts`) used across API routes for caching external API responses
|
||||
- **External APIs**: Last.fm (music), Steam (games), PSN (PlayStation games), Giant Bomb (game metadata)
|
||||
- **Data enrichment**: Last.fm data is enhanced with iTunes artwork and Giant Bomb metadata
|
||||
|
||||
**Frontend Structure**
|
||||
|
||||
- **Component-based**: Reusable Svelte components in `$lib/components/`
|
||||
- **Page composition**: Main page (`+page.svelte`) composed of multiple `Page` components with different content sections
|
||||
- **Data loading**: Server-side data fetching in `+page.ts` with error handling
|
||||
|
||||
**Styling System**
|
||||
|
||||
- **SCSS-based**: Global variables, fonts, themes automatically imported via Vite config
|
||||
- **Asset management**: SVG icons and illustrations with automatic processing and alias imports
|
||||
|
||||
### Key Aliases (svelte.config.js)
|
||||
|
||||
- `$components` → `src/lib/components`
|
||||
- `$icons` → `src/assets/icons`
|
||||
- `$illos` → `src/assets/illos`
|
||||
- `$styles` → `src/styles`
|
||||
|
||||
### Environment Dependencies
|
||||
|
||||
- Requires `LASTFM_API_KEY` and `REDIS_URL` environment variables
|
||||
- Uses Node.js adapter for deployment
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import globals from 'globals';
|
||||
import js from '@eslint/js'
|
||||
import ts from 'typescript-eslint'
|
||||
import svelte from 'eslint-plugin-svelte'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import globals from 'globals'
|
||||
|
||||
/** @type {import('eslint').Linter.FlatConfig[]} */
|
||||
export default [
|
||||
|
|
@ -30,4 +30,4 @@ export default [
|
|||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
||||
}
|
||||
];
|
||||
]
|
||||
|
|
|
|||
122
package-lock.json
generated
122
package-lock.json
generated
|
|
@ -13,7 +13,9 @@
|
|||
"@types/steamapi": "^2.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"giantbombing-api": "^1.0.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"marked": "^15.0.12",
|
||||
"node-itunes-search": "^1.2.3",
|
||||
"psn-api": "github:jedmund/psn-api",
|
||||
"redis": "^4.7.0",
|
||||
|
|
@ -2471,6 +2473,19 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esquery": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
|
||||
|
|
@ -2533,6 +2548,18 @@
|
|||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/extsprintf": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
|
||||
|
|
@ -2881,6 +2908,43 @@
|
|||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/har-schema": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
|
||||
|
|
@ -3069,6 +3133,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -3261,6 +3334,15 @@
|
|||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
|
|
@ -3348,6 +3430,18 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
|
|
@ -4196,6 +4290,19 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.6.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
|
||||
|
|
@ -4288,6 +4395,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
|
|
@ -4421,6 +4534,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-indent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@
|
|||
"@types/steamapi": "^2.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"giantbombing-api": "^1.0.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ioredis": "^5.4.1",
|
||||
"marked": "^15.0.12",
|
||||
"node-itunes-search": "^1.2.3",
|
||||
"psn-api": "github:jedmund/psn-api",
|
||||
"redis": "^4.7.0",
|
||||
|
|
|
|||
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
|
|
@ -10,4 +10,4 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export {}
|
||||
|
|
|
|||
3
src/assets/icons/labs.svg
Normal file
3
src/assets/icons/labs.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.067 2.544C17.8009 2.75935 18.0308 3.68511 17.4834 4.21918L16.6819 5.00175C16.6195 5.06266 16.5738 5.1387 16.5499 5.22256L15.3478 9.45881C15.2919 9.6565 15.3639 9.86682 15.5211 9.99916C17.5805 11.7326 18.538 14.5803 17.7317 17.329C16.6434 21.0387 12.7539 23.1637 9.04422 22.0754C5.33457 20.9871 3.20954 17.0976 4.29783 13.3879C5.11503 10.6024 7.51165 8.70987 10.2256 8.40086C10.4344 8.37709 10.6146 8.23634 10.674 8.03472L11.899 3.86933C11.9255 3.77931 11.926 3.68337 11.9007 3.59301L11.6042 2.53614C11.3922 1.77984 12.0956 1.08554 12.8493 1.30664L17.067 2.544ZM15.4326 15.9971C15.5185 15.4516 15.0307 15.0293 14.4796 15.0652L7.49528 15.5187C6.94416 15.5546 6.51471 16.0375 6.67067 16.5673C7.2519 18.5414 9.13946 19.9216 11.279 19.7827C13.4189 19.6436 15.112 18.0301 15.4326 15.9971Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 890 B |
3
src/assets/icons/universe.svg
Normal file
3
src/assets/icons/universe.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.2299 5.99489L10.2978 3.12574C9.8848 1.85434 8.0806 1.87018 7.68997 3.14863L6.82721 5.97228C6.69024 6.42056 6.33317 6.76742 5.88111 6.89134L2.99869 7.68143C1.65766 8.04902 1.65766 9.95097 2.99869 10.3186L5.88111 11.1087C6.33317 11.2326 6.69024 11.5794 6.82721 12.0277L7.68997 14.8514C8.0806 16.1298 9.8848 16.1457 10.2978 14.8743L11.2299 12.0051C11.3714 11.5695 11.7216 11.2337 12.1627 11.1106L15.0073 10.3169C16.3403 9.94495 16.3403 8.05505 15.0073 7.6831L12.1627 6.88939C11.7216 6.76629 11.3714 6.43048 11.2299 5.99489Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 628 B |
3
src/assets/icons/work.svg
Normal file
3
src/assets/icons/work.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.90458 4.27439L5.03512 16.1109C5.22989 17.193 6.72825 17.3263 7.11093 16.2955L8.65655 12.1326C8.84013 11.6381 9.30501 11.3042 9.83221 11.2881L14.0645 11.1589C15.1441 11.1259 15.5123 9.70402 14.5844 9.15119L4.52063 3.15504C3.72483 2.68089 2.74048 3.3627 2.90458 4.27439Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
|
|
@ -25,6 +25,7 @@ $unit-20x: $unit * 20;
|
|||
/* Page properties
|
||||
* -------------------------------------------------------------------------- */
|
||||
$page-corner-radius: $unit;
|
||||
$card-corner-radius: $unit-3x;
|
||||
|
||||
$page-top-margin: $unit-6x;
|
||||
|
||||
|
|
@ -86,6 +87,20 @@ $grey-color: #f0f0f0;
|
|||
|
||||
$image-border-color: rgba(0, 0, 0, 0.03);
|
||||
|
||||
/* Shadows
|
||||
* -------------------------------------------------------------------------- */
|
||||
$card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
$card-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Pill colors
|
||||
* -------------------------------------------------------------------------- */
|
||||
$work-bg: #ffcdc5;
|
||||
$work-color: #d0290d;
|
||||
$universe-bg: #ffebc5;
|
||||
$universe-color: #b97d14;
|
||||
$labs-bg: #c5eaff;
|
||||
$labs-color: #1482c1;
|
||||
|
||||
$facebook-color: #3b5998;
|
||||
$twitter-color: #55acee;
|
||||
$instagram-color: #3f729b;
|
||||
|
|
|
|||
52
src/lib/components/Header.svelte
Normal file
52
src/lib/components/Header.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
import Avatar from './Avatar.svelte'
|
||||
import SegmentedController from './SegmentedController.svelte'
|
||||
</script>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="header-content">
|
||||
<a href="/" class="header-link" aria-label="@jedmund">
|
||||
<Avatar />
|
||||
</a>
|
||||
<SegmentedController />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
.site-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: $unit-4x 0;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.header-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
height: 52px; // Reduced by 4px for optical balance
|
||||
|
||||
:global(.face-container) {
|
||||
height: 52px;
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
:global(svg) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
src/lib/components/Pill.svelte
Normal file
84
src/lib/components/Pill.svelte
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import type { ComponentType } from 'svelte'
|
||||
|
||||
let {
|
||||
icon,
|
||||
text,
|
||||
href,
|
||||
active = false,
|
||||
variant = 'default'
|
||||
}: {
|
||||
icon: ComponentType
|
||||
text: string
|
||||
href?: string
|
||||
active?: boolean
|
||||
variant?: 'work' | 'universe' | 'default'
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<a {href} class="pill {variant}" class:active>
|
||||
<svelte:component this={icon} />
|
||||
<span>{text}</span>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 100px;
|
||||
text-decoration: none;
|
||||
color: $grey-20; // #666
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
:global(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
// Work variant
|
||||
&.work {
|
||||
&:hover,
|
||||
&.active {
|
||||
background: $work-bg;
|
||||
color: $work-color;
|
||||
|
||||
:global(svg) {
|
||||
fill: $work-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Universe variant
|
||||
&.universe {
|
||||
&:hover,
|
||||
&.active {
|
||||
background: $universe-bg;
|
||||
color: $universe-color;
|
||||
|
||||
:global(svg) {
|
||||
fill: $universe-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default variant (Labs)
|
||||
&.default {
|
||||
&:hover,
|
||||
&.active {
|
||||
background: $labs-bg;
|
||||
color: $labs-color;
|
||||
|
||||
:global(svg) {
|
||||
fill: $labs-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
170
src/lib/components/PostContent.svelte
Normal file
170
src/lib/components/PostContent.svelte
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<script lang="ts">
|
||||
import type { Post } from '$lib/posts'
|
||||
|
||||
let { post }: { post: Post } = $props()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="post-content {post.type}">
|
||||
<header class="post-header">
|
||||
{#if post.title}
|
||||
<h1 class="post-title">{post.title}</h1>
|
||||
{/if}
|
||||
<time class="post-date" datetime={post.date}>
|
||||
{formatDate(post.date)}
|
||||
</time>
|
||||
</header>
|
||||
|
||||
<div class="post-body">
|
||||
{@html post.content}
|
||||
</div>
|
||||
|
||||
<footer class="post-footer">
|
||||
<a href="/blog" class="back-link">← Back to all posts</a>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
.post-content {
|
||||
max-width: 784px;
|
||||
margin: 0 auto;
|
||||
|
||||
&.note {
|
||||
.post-body {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-header {
|
||||
margin-bottom: $unit-5x;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin: 0 0 $unit-2x;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: $grey-20;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: $grey-40;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.post-body {
|
||||
color: $grey-20;
|
||||
line-height: 1.6;
|
||||
|
||||
:global(h2) {
|
||||
margin: $unit-4x 0 $unit-2x;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
:global(h3) {
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: 0 0 $unit-3x;
|
||||
padding-left: $unit-3x;
|
||||
}
|
||||
|
||||
:global(ul li),
|
||||
:global(ol li) {
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
:global(blockquote) {
|
||||
margin: $unit-3x 0;
|
||||
padding-left: $unit-3x;
|
||||
border-left: 3px solid $grey-80;
|
||||
color: $grey-40;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(code) {
|
||||
background: $grey-90;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:global(pre) {
|
||||
background: $grey-90;
|
||||
padding: $unit-2x;
|
||||
border-radius: $unit;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(a:hover) {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid $grey-80;
|
||||
margin: $unit-4x 0;
|
||||
}
|
||||
|
||||
:global(em) {
|
||||
font-style: italic;
|
||||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
margin-top: $unit-6x;
|
||||
padding-top: $unit-4x;
|
||||
border-top: 1px solid $grey-80;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
src/lib/components/PostItem.svelte
Normal file
79
src/lib/components/PostItem.svelte
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts">
|
||||
import type { Post } from '$lib/posts'
|
||||
|
||||
let { post }: { post: Post } = $props()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="post-item {post.type}">
|
||||
<a href="/blog/{post.slug}" class="post-link">
|
||||
{#if post.title}
|
||||
<h2 class="post-title">{post.title}</h2>
|
||||
{/if}
|
||||
<p class="post-excerpt">{post.excerpt}</p>
|
||||
<time class="post-date" datetime={post.date}>
|
||||
{formatDate(post.date)}
|
||||
</time>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
.post-item {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
|
||||
&.note {
|
||||
.post-excerpt {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: $unit-3x;
|
||||
background: $grey-100;
|
||||
border-radius: $card-corner-radius;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
// Hover styles can be added here if needed
|
||||
}
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin: 0 0 $unit;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: $red-60;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-00;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
color: $grey-40;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
24
src/lib/components/PostList.svelte
Normal file
24
src/lib/components/PostList.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
import type { Post } from '$lib/posts'
|
||||
import PostItem from './PostItem.svelte'
|
||||
|
||||
let { posts }: { posts: Post[] } = $props()
|
||||
</script>
|
||||
|
||||
<div class="post-list">
|
||||
{#if posts && posts.length > 0}
|
||||
{#each posts as post}
|
||||
<PostItem {post} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p>No posts found.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.post-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
</style>
|
||||
27
src/lib/components/SegmentedController.svelte
Normal file
27
src/lib/components/SegmentedController.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import Pill from './Pill.svelte'
|
||||
import WorkIcon from '$icons/work.svg'
|
||||
import LabsIcon from '$icons/labs.svg'
|
||||
import UniverseIcon from '$icons/universe.svg'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
$: currentPath = $page.url.pathname
|
||||
</script>
|
||||
|
||||
<nav class="segmented-controller">
|
||||
<Pill icon={WorkIcon} text="Work" href="/" active={currentPath === '/'} variant="work" />
|
||||
<Pill icon={LabsIcon} text="Labs" href="#" active={false} variant="default" />
|
||||
<Pill icon={UniverseIcon} text="Universe" href="/blog" active={currentPath.startsWith('/blog')} variant="universe" />
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
.segmented-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: $grey-100;
|
||||
padding: $unit;
|
||||
border-radius: 100px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
63
src/lib/posts.ts
Normal file
63
src/lib/posts.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import matter from 'gray-matter'
|
||||
import { marked } from 'marked'
|
||||
|
||||
export interface Post {
|
||||
title?: string
|
||||
type: 'note' | 'article' | 'image'
|
||||
date: string
|
||||
slug: string
|
||||
published: boolean
|
||||
content: string
|
||||
excerpt?: string
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
const postsDirectory = path.join(process.cwd(), 'src/lib/posts')
|
||||
|
||||
export async function getAllPosts(): Promise<Post[]> {
|
||||
const fileNames = fs.readdirSync(postsDirectory)
|
||||
|
||||
const posts = fileNames
|
||||
.filter((fileName) => fileName.endsWith('.md'))
|
||||
.map((fileName) => {
|
||||
const filePath = path.join(postsDirectory, fileName)
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8')
|
||||
const { data, content } = matter(fileContents)
|
||||
|
||||
const slug = data.slug || fileName.replace(/\.md$/, '')
|
||||
|
||||
return {
|
||||
...data,
|
||||
slug,
|
||||
content,
|
||||
excerpt: getExcerpt(content, data.type)
|
||||
} as Post
|
||||
})
|
||||
.filter((post) => post.published)
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string): Promise<Post | null> {
|
||||
const posts = await getAllPosts()
|
||||
const post = posts.find((p) => p.slug === slug)
|
||||
|
||||
if (!post) return null
|
||||
|
||||
return {
|
||||
...post,
|
||||
content: marked(post.content) as string
|
||||
}
|
||||
}
|
||||
|
||||
function getExcerpt(content: string, type: 'note' | 'article'): string {
|
||||
const plainText = content.replace(/[#*`\[\]]/g, '').trim()
|
||||
const maxLength = type === 'note' ? 280 : 160
|
||||
|
||||
if (plainText.length <= maxLength) return plainText
|
||||
|
||||
return plainText.substring(0, maxLength).trim() + '...'
|
||||
}
|
||||
8
src/lib/posts/quick-thought-about-design-systems.md
Normal file
8
src/lib/posts/quick-thought-about-design-systems.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
type: 'note'
|
||||
date: '2024-01-16T14:20:00Z'
|
||||
slug: 'quick-thought-about-design-systems'
|
||||
published: true
|
||||
---
|
||||
|
||||
Been thinking about how the best design systems aren't the ones with the most components, but the ones with the clearest principles. You can have a thousand perfectly crafted components, but if your team doesn't understand the _why_ behind them, you've just built a very pretty prison.
|
||||
8
src/lib/posts/svg-animations-are-fun.md
Normal file
8
src/lib/posts/svg-animations-are-fun.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
type: 'note'
|
||||
date: '2024-01-20T16:45:00Z'
|
||||
slug: 'svg-animations-are-fun'
|
||||
published: true
|
||||
---
|
||||
|
||||
Just spent an hour making a squiggly line animation for my site. Could I have shipped three features in that time? Yes. Did the squiggly line spark more joy? Also yes. Not everything needs to be optimized for productivity.
|
||||
48
src/lib/posts/the-perfect-todo-app-doesnt-exist.md
Normal file
48
src/lib/posts/the-perfect-todo-app-doesnt-exist.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
title: "The Perfect Todo App Doesn't Exist"
|
||||
type: 'article'
|
||||
date: '2024-01-18T09:00:00Z'
|
||||
slug: 'the-perfect-todo-app-doesnt-exist'
|
||||
published: true
|
||||
---
|
||||
|
||||
I've tried them all. Things, Todoist, Notion, Apple Reminders, pen and paper, sticky notes on my monitor. Each promises to be the solution to my productivity woes, and each eventually joins the graveyard of abandoned systems.
|
||||
|
||||
## The Cycle
|
||||
|
||||
It always starts the same way:
|
||||
|
||||
1. Discover new todo app
|
||||
2. Feel rush of organizational dopamine
|
||||
3. Migrate all tasks with excessive enthusiasm
|
||||
4. Use religiously for 2-3 weeks
|
||||
5. Gradually abandon as real life intrudes
|
||||
6. Feel guilty about abandoned system
|
||||
7. Return to step 1
|
||||
|
||||
## The Problem Isn't the Tool
|
||||
|
||||
After years of this cycle, I've realized something: the perfect todo app doesn't exist because the problem isn't the app. It's the expectation that any tool can magically transform us into productivity machines.
|
||||
|
||||
Real productivity isn't about finding the perfect system. It's about:
|
||||
|
||||
- Saying no to things that don't matter
|
||||
- Being realistic about what you can accomplish
|
||||
- Accepting that some days you'll get nothing done
|
||||
- Understanding that busy isn't the same as productive
|
||||
|
||||
## What Works (For Me)
|
||||
|
||||
These days, I use a simple text file. One for today, one for this week, one for "someday maybe." No tags, no priorities, no due dates unless absolutely necessary. Just a list of things I'd like to do.
|
||||
|
||||
Some get done. Some don't. The world keeps spinning.
|
||||
|
||||
## The Real Secret
|
||||
|
||||
The perfect todo app doesn't exist because perfection doesn't exist. The best system is the one you'll actually use, even if it's just a piece of paper or a note on your phone.
|
||||
|
||||
Stop optimizing your system. Start doing the work.
|
||||
|
||||
---
|
||||
|
||||
_What's your relationship with todo apps? Have you found something that works, or are you still searching for the perfect system?_
|
||||
33
src/lib/posts/welcome-to-my-blog.md
Normal file
33
src/lib/posts/welcome-to-my-blog.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
title: 'Welcome to My Blog'
|
||||
type: 'article'
|
||||
date: '2024-01-15T10:30:00Z'
|
||||
slug: 'welcome-to-my-blog'
|
||||
published: true
|
||||
---
|
||||
|
||||
After years of sharing my thoughts across various social platforms, I've decided to bring everything home. This blog will be a space for both quick notes and longer-form thoughts about design, development, and whatever else catches my interest.
|
||||
|
||||
## Why Now?
|
||||
|
||||
The internet feels different these days. Social media platforms come and go, APIs change, and our content gets scattered across dozens of services. I wanted a simple, permanent place for my writing that I control completely.
|
||||
|
||||
## What to Expect
|
||||
|
||||
You'll find a mix of content here:
|
||||
|
||||
- **Notes**: Quick thoughts, observations, and links I find interesting
|
||||
- **Articles**: Deeper dives into topics I'm passionate about
|
||||
- **Updates**: What I'm working on and thinking about
|
||||
|
||||
The design is intentionally minimal. No comments, no likes, no algorithms—just words on a page, the way blogs used to be.
|
||||
|
||||
## Technical Details
|
||||
|
||||
This blog is built with SvelteKit and uses markdown files for storage. It's fast, simple, and exactly what I need. No database, no CMS, just files in a folder.
|
||||
|
||||
Feel free to view source if you're curious about the implementation. Everything is open and straightforward.
|
||||
|
||||
---
|
||||
|
||||
_Thanks for reading. Here's to owning our own words._
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Header from '$components/Header.svelte'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -11,6 +12,8 @@ user-scalable=no"
|
|||
/>
|
||||
</svelte:head>
|
||||
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Album from '$components/Album.svelte'
|
||||
import Avatar from '$components/Avatar.svelte'
|
||||
import Game from '$components/Game.svelte'
|
||||
import MentionList from '$components/MentionList.svelte'
|
||||
import Page from '$components/Page.svelte'
|
||||
|
|
@ -16,9 +15,6 @@
|
|||
|
||||
<Page>
|
||||
<svelte:fragment slot="header">
|
||||
<h1 aria-label="@jedmund">
|
||||
<Avatar />
|
||||
</h1>
|
||||
<h2 class="subheader">@jedmund is a software designer</h2>
|
||||
</svelte:fragment>
|
||||
|
||||
|
|
@ -49,6 +45,9 @@
|
|||
target="_blank">Carnegie Mellon University</a
|
||||
> in 2011 with a Bachelors of Arts in Communication Design.
|
||||
</p>
|
||||
<p>
|
||||
I occasionally write about design and development on my <a href="/blog">blog</a>.
|
||||
</p>
|
||||
</section>
|
||||
</Page>
|
||||
<Page>
|
||||
|
|
|
|||
10
src/routes/blog/+page.server.ts
Normal file
10
src/routes/blog/+page.server.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { getAllPosts } from '$lib/posts'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return {
|
||||
posts
|
||||
}
|
||||
}
|
||||
34
src/routes/blog/+page.svelte
Normal file
34
src/routes/blog/+page.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import PostList from '$components/PostList.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Blog - jedmund</title>
|
||||
<meta name="description" content="Thoughts on design, development, and everything in between." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="blog-container">
|
||||
<PostList posts={data.posts} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.blog-container {
|
||||
max-width: 784px;
|
||||
margin: $unit-6x auto;
|
||||
padding: 0 $unit-5x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
margin-top: $unit-3x;
|
||||
margin-bottom: $unit-3x;
|
||||
padding: 0 $unit-3x;
|
||||
}
|
||||
|
||||
@include breakpoint('small-phone') {
|
||||
padding: 0 $unit-2x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/routes/blog/[slug]/+page.server.ts
Normal file
15
src/routes/blog/[slug]/+page.server.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { getPostBySlug } from '$lib/posts'
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const post = await getPostBySlug(params.slug)
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Post not found')
|
||||
}
|
||||
|
||||
return {
|
||||
post
|
||||
}
|
||||
}
|
||||
18
src/routes/blog/[slug]/+page.svelte
Normal file
18
src/routes/blog/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import PostContent from '$components/PostContent.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const pageTitle = data.post.title || 'Blog post'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} - jedmund</title>
|
||||
<meta name="description" content={data.post.excerpt} />
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PostContent post={data.post} />
|
||||
</Page>
|
||||
Loading…
Reference in a new issue