mirror of
https://github.com/cupcakearmy/nicco.io.git
synced 2024-12-22 16:16:24 +00:00
Compare commits
17 Commits
6d1a835972
...
f34c892dbe
Author | SHA1 | Date | |
---|---|---|---|
f34c892dbe | |||
eea86dfc01 | |||
2a61eba012 | |||
5e34338bf7 | |||
940cd5cea9 | |||
f1449a157c | |||
6250f4b5bd | |||
75d19fa5d9 | |||
b5af0cae15 | |||
65eb4bb712 | |||
a8995c7428 | |||
ccec3806f2 | |||
44efa2047a | |||
40cbf9b82e | |||
5615e62d41 | |||
d4e9b2027e | |||
13eb767fa0 |
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1,2 +1,3 @@
|
||||
**/images/* filter=lfs diff=lfs merge=lfs -text
|
||||
*.afphoto filter=lfs diff=lfs merge=lfs -text
|
||||
*.afdesign filter=lfs diff=lfs merge=lfs -text
|
||||
|
32
.gitignore
vendored
32
.gitignore
vendored
@ -1,8 +1,26 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
node_modules
|
||||
/.svelte-kit
|
||||
/package
|
||||
/build
|
||||
.vercel_build_output
|
||||
.vercel
|
||||
*.wpress
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
output
|
||||
export.xml
|
||||
|
@ -1 +0,0 @@
|
||||
schema: "https://api.nicco.io/graphql"
|
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
39
README.md
39
README.md
@ -1,38 +1,3 @@
|
||||
# create-svelte
|
||||
# nicco.io
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte);
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm init svelte@next
|
||||
|
||||
# create a new project in my-app
|
||||
npm init svelte@next my-app
|
||||
```
|
||||
|
||||
> Note: the `@next` is temporary
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production.
|
||||
Personal blog and website. With astro and a bit of svelte
|
||||
|
22
astro.config.mjs
Normal file
22
astro.config.mjs
Normal file
@ -0,0 +1,22 @@
|
||||
// @ts-check
|
||||
import { rehypeHeadingIds } from '@astrojs/markdown-remark'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import svelte from '@astrojs/svelte'
|
||||
import { defineConfig } from 'astro/config'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import Icons from 'unplugin-icons/vite'
|
||||
import { remarkReadingTime } from './readingTime'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://example.com',
|
||||
integrations: [mdx(), sitemap(), svelte()],
|
||||
markdown: {
|
||||
rehypePlugins: [rehypeHeadingIds, [rehypeAutolinkHeadings, { behavior: 'wrap' }]],
|
||||
remarkPlugins: [remarkReadingTime],
|
||||
},
|
||||
vite: {
|
||||
plugins: [Icons({ compiler: 'astro' })],
|
||||
},
|
||||
})
|
14
codegen.yaml
14
codegen.yaml
@ -1,14 +0,0 @@
|
||||
schema: https://api.nicco.io/graphql
|
||||
documents: "src/**/*.graphql"
|
||||
generates:
|
||||
./src/lib/gql/gen.ts:
|
||||
plugins:
|
||||
- "@graphql-codegen/typescript"
|
||||
- "@graphql-codegen/typescript-operations"
|
||||
- "@graphql-codegen/typescript-graphql-request"
|
||||
config:
|
||||
maybeValue: "T"
|
||||
typesPrefix: GQL
|
||||
immutableTypes: true
|
||||
useTypeImports: true
|
||||
avoidOptionals: true
|
61
package.json
61
package.json
@ -1,37 +1,34 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "svelte-kit dev",
|
||||
"build": "svelte-kit build",
|
||||
"preview": "svelte-kit preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"generate": "graphql-codegen",
|
||||
"ci": "pnpm run generate && pnpm run check && pnpm run build"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.6.2",
|
||||
"@graphql-codegen/typescript": "^2.4.7",
|
||||
"@graphql-codegen/typescript-graphql-request": "^4.4.2",
|
||||
"@graphql-codegen/typescript-operations": "^2.3.4",
|
||||
"@sveltejs/adapter-static": "^1.0.0-next.29",
|
||||
"@sveltejs/kit": "^1.0.0-next.295",
|
||||
"@types/lunr": "^2.3.4",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-request": "^3.7.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"svelte": "^3.46.4",
|
||||
"svelte-check": "^2.4.5",
|
||||
"svelte-preprocess": "^4.10.4",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.6.2"
|
||||
"scripts": {
|
||||
"astro": "astro",
|
||||
"build": "astro check && astro build",
|
||||
"dev": "astro dev",
|
||||
"preview": "astro preview",
|
||||
"start": "astro dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.10.8",
|
||||
"highlight.js": "^11.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lunr": "^2.3.9",
|
||||
"svelte-cloudinary": "^0.2.5"
|
||||
}
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/markdown-remark": "^6.0.0",
|
||||
"@astrojs/mdx": "^4.0.1",
|
||||
"@astrojs/rss": "^4.0.9",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@astrojs/svelte": "^7.0.1",
|
||||
"@fontsource-variable/jost": "^5.1.1",
|
||||
"@fontsource-variable/playfair-display": "^5.1.0",
|
||||
"@iconify-json/ion": "^1.2.1",
|
||||
"astro": "^5.0.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"remark-toc": "^9.0.0",
|
||||
"sass": "^1.81.1",
|
||||
"sharp": "^0.33.5",
|
||||
"svelte": "^5.5.2",
|
||||
"typescript": "^5.7.2",
|
||||
"unplugin-icons": "^0.21.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.14.4"
|
||||
}
|
||||
|
9071
pnpm-lock.yaml
generated
9071
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
9
public/favicon.svg
Normal file
9
public/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
After Width: | Height: | Size: 749 B |
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
11
readingTime.js
Normal file
11
readingTime.js
Normal file
@ -0,0 +1,11 @@
|
||||
import getReadingTime from 'reading-time'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
|
||||
export function remarkReadingTime() {
|
||||
return function (tree, { data }) {
|
||||
const textOnPage = toString(tree)
|
||||
const readingTime = getReadingTime(textOnPage)
|
||||
data.astro.frontmatter.text = textOnPage
|
||||
data.astro.frontmatter.readingTime = readingTime
|
||||
}
|
||||
}
|
23
src/app.html
23
src/app.html
@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="Designer & Developer" />
|
||||
<meta name="keywords" content="Web Agency Blog Articles" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover" />
|
||||
|
||||
<link rel="icon" type="image/png" href="/images/monogramm.png" />
|
||||
|
||||
%svelte.head%
|
||||
</head>
|
||||
<body>
|
||||
<div id="svelte">%svelte.body%</div>
|
||||
|
||||
<script
|
||||
async
|
||||
defer
|
||||
data-website-id="2c23f7af-230c-4ea3-a40a-87aa2939fef3"
|
||||
src="https://spectare.nicco.io/unicorn.js"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
30
src/components/AboutImage.astro
Normal file
30
src/components/AboutImage.astro
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
import { Image } from 'astro:assets'
|
||||
import aboutImage from '../content/images/about.webp'
|
||||
---
|
||||
|
||||
<Image src={aboutImage} alt={'tiny me'} />
|
||||
|
||||
<style>
|
||||
img {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
object-fit: contain;
|
||||
width: 24vw;
|
||||
height: 30vw;
|
||||
left: 40em;
|
||||
top: 12em;
|
||||
max-width: 25em;
|
||||
}
|
||||
|
||||
@media (max-width: 60em) {
|
||||
img {
|
||||
position: initial;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-position: right;
|
||||
max-height: 20em;
|
||||
margin-top: 4em;
|
||||
}
|
||||
}
|
||||
</style>
|
58
src/components/BaseHead.astro
Normal file
58
src/components/BaseHead.astro
Normal file
@ -0,0 +1,58 @@
|
||||
---
|
||||
import '@fontsource-variable/jost'
|
||||
import '@fontsource-variable/playfair-display'
|
||||
|
||||
import '../styles/preflight.css'
|
||||
import '../styles/global.scss'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
|
||||
|
||||
// const { image = '/blog-placeholder-1.jpg' } = Astro.props
|
||||
|
||||
let title = 'Niccolo Borgioli'
|
||||
const description = 'Personal blog'
|
||||
|
||||
if (Astro.props.title) {
|
||||
title = Astro.props.title + ' - ' + title
|
||||
}
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/monogram.png" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<!-- <meta property="og:image" content={new URL(image, Astro.url)} /> -->
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<!-- <meta property="twitter:image" content={new URL(image, Astro.url)} /> -->
|
||||
|
||||
<script
|
||||
is:inline
|
||||
async
|
||||
defer
|
||||
src="https://spectare.nicco.io/unicorn.js"
|
||||
data-website-id="bc7525c5-6928-49e1-9255-aca296947def"></script>
|
53
src/components/ContactLinks.astro
Normal file
53
src/components/ContactLinks.astro
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
import FingerPrint from '~icons/ion/finger-print'
|
||||
import ChatBubbles from '~icons/ion/chatbubbles-outline'
|
||||
import Mail from '~icons/ion/mail-outline'
|
||||
import Heart from '~icons/ion/heart'
|
||||
import Github from '~icons/ion/logo-github'
|
||||
import Trending from '~icons/ion/trending-up'
|
||||
|
||||
const links: { label: string; url: string; icon: astroHTML.JSX.Element }[] = [
|
||||
{ label: 'Say hi@nicco.io', url: 'mailto:hi@nicco.io', icon: Mail },
|
||||
{ label: 'Chat on discord', url: 'https://discord.gg/wS7RpYTYd2', icon: ChatBubbles },
|
||||
{ label: 'GitHub', url: 'https://github.com/cupcakearmy', icon: Github },
|
||||
{ label: 'Support my work', url: 'https://github.com/sponsors/cupcakearmy', icon: Heart },
|
||||
{ label: 'Traffic', url: 'https://spectare.nicco.io/share/HYgOcrlfHxGB9RAR/nicco.io', icon: Trending },
|
||||
{ label: 'Privacy', url: '/privacy', icon: FingerPrint },
|
||||
]
|
||||
---
|
||||
|
||||
<ul>
|
||||
{
|
||||
links.map(({ label, url, icon: Icon }) => {
|
||||
return (
|
||||
<li>
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
{Icon && <Icon />}
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
transition: var(--animation);
|
||||
|
||||
&:hover {
|
||||
transform: translateX(5%) scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
17
src/components/FormattedDate.astro
Normal file
17
src/components/FormattedDate.astro
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
date: Date
|
||||
}
|
||||
|
||||
const { date } = Astro.props
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()}>
|
||||
{
|
||||
date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</time>
|
@ -1,39 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import Icon from './Icon.svelte'
|
||||
---
|
||||
import SearchIcon from '~icons/ion/search-outline'
|
||||
const { pathname } = Astro.url
|
||||
|
||||
const routes = [
|
||||
{ name: 'About', href: '/about' },
|
||||
// { name: 'Works', href: '/works' },
|
||||
{ name: 'Projects', href: '/projects' },
|
||||
{ name: 'Blog', href: '/blog' },
|
||||
{ name: 'Contact', href: '/contact' },
|
||||
{ name: 'Rest', href: '/rest' },
|
||||
]
|
||||
</script>
|
||||
---
|
||||
|
||||
<nav>
|
||||
<a href="/">
|
||||
<h1 class:active={$page.url.pathname === '/'}>NB</h1>
|
||||
<h1 class:list={{ active: pathname === '/' }}>NB</h1>
|
||||
</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/search">
|
||||
<Icon icon="search-outline" />
|
||||
<SearchIcon />
|
||||
</a>
|
||||
</li>
|
||||
{#each routes as { name, href }}
|
||||
{
|
||||
routes.map(({ href, name }) => (
|
||||
<li>
|
||||
<a {href}>
|
||||
<span>{name}</span>
|
||||
<div class:active={$page.url.pathname.startsWith(href)} />
|
||||
<div class:list={{ active: pathname.startsWith(href) }} />
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* OLD */
|
||||
nav :global(*) {
|
||||
box-sizing: initial;
|
||||
}
|
66
src/components/PageSearch.svelte
Normal file
66
src/components/PageSearch.svelte
Normal file
@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
const { entries } = $props()
|
||||
|
||||
const fuse = new Fuse(entries, {
|
||||
keys: ['text', 'url', 'extra'],
|
||||
includeScore: true,
|
||||
includeMatches: false,
|
||||
minMatchCharLength: 2,
|
||||
threshold: 0.5,
|
||||
})
|
||||
|
||||
let needle = $state('')
|
||||
const results = $derived(fuse.search(needle))
|
||||
</script>
|
||||
|
||||
<input bind:value={needle} />
|
||||
|
||||
<ol>
|
||||
{#each results as result}
|
||||
<li>
|
||||
<a href={result.item.url}>
|
||||
<span class="meta">
|
||||
{result.item.type}
|
||||
</span>
|
||||
<span class="meta">
|
||||
{(1 - result.score).toFixed(2)}
|
||||
</span>
|
||||
{result.item.title}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style lang="scss">
|
||||
input {
|
||||
background: white;
|
||||
border: 2px solid var(--clr-primary);
|
||||
border-bottom-width: 4px;
|
||||
padding: 1rem;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
outline: none;
|
||||
border-color: var(--clr-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
background: var(--clr-secondary);
|
||||
width: fit-content;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
</style>
|
44
src/components/PostAttributes.astro
Normal file
44
src/components/PostAttributes.astro
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import type { ReadTimeResults } from 'reading-time'
|
||||
import FormattedDate from './FormattedDate.astro'
|
||||
import ReadingTime from './ReadingTime.astro'
|
||||
|
||||
type Props = {
|
||||
readingTime: ReadTimeResults
|
||||
} & Pick<CollectionEntry<'blog'>['data'], 'date' | 'updatedDate'>
|
||||
|
||||
const { updatedDate, date, readingTime } = Astro.props
|
||||
---
|
||||
|
||||
<div class="attributes">
|
||||
<div class="estimation">
|
||||
<FormattedDate date={date} />
|
||||
{
|
||||
updatedDate && date !== updatedDate && (
|
||||
<div class="updated">
|
||||
Updated: <FormattedDate date={updatedDate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<ReadingTime readingTime={readingTime} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.attributes {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 400;
|
||||
margin-top: -0.125em;
|
||||
}
|
||||
.estimation {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.updated {
|
||||
font-size: 0.75em;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
32
src/components/PostList.astro
Normal file
32
src/components/PostList.astro
Normal file
@ -0,0 +1,32 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import PostPreview from './PostPreview.astro'
|
||||
|
||||
export type Props = {
|
||||
posts: CollectionEntry<'blog'>[]
|
||||
}
|
||||
|
||||
const { posts } = Astro.props
|
||||
---
|
||||
|
||||
<ul>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.slug}`}>
|
||||
<PostPreview {post} />
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
ul {
|
||||
max-width: 40rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rem;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
77
src/components/PostPreview.astro
Normal file
77
src/components/PostPreview.astro
Normal file
@ -0,0 +1,77 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
import PostAttributes from './PostAttributes.astro'
|
||||
import Tags from './Tags.astro'
|
||||
|
||||
export type Props = {
|
||||
post: CollectionEntry<'blog'>
|
||||
}
|
||||
|
||||
const { post } = Astro.props
|
||||
const { remarkPluginFrontmatter } = await post.render()
|
||||
---
|
||||
|
||||
<section class:list={{ without: !post.data.coverImage }}>
|
||||
{post.data.coverImage && <Picture src={post.data.coverImage} alt={'foo'} />}
|
||||
<PostAttributes
|
||||
date={post.data.date}
|
||||
updatedDate={post.data.updatedDate}
|
||||
readingTime={remarkPluginFrontmatter.readingTime}
|
||||
/>
|
||||
<h2>
|
||||
{post.data.title}
|
||||
</h2>
|
||||
<Tags tags={post.data.tags.map((tag) => ({ name: tag, href: `/tag/${tag}` }))} />
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: block;
|
||||
transition: var(--animation);
|
||||
|
||||
// &.without {
|
||||
// border: 2px solid var(--clr-primary);
|
||||
// padding: 5%;
|
||||
// width: calc(100% + 10%);
|
||||
// transform: translateX(-5%);
|
||||
// }
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
& :global(img) {
|
||||
}
|
||||
// & > :global(div) {
|
||||
// opacity: 0;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0.25rem;
|
||||
position: relative;
|
||||
top: 0;
|
||||
transition: var(--animation);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
section > :global(div) {
|
||||
opacity: 1;
|
||||
transition: var(--animation);
|
||||
}
|
||||
|
||||
img {
|
||||
width: calc(100% - 0.25em);
|
||||
height: 12rem;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
border: 0.125rem solid var(--clr-primary);
|
||||
transition: var(--animation);
|
||||
transform: scale(1);
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
top: 0;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { spring } from 'svelte/motion'
|
||||
import { scroll } from '$lib/stores'
|
||||
|
||||
let el: SVGElement
|
||||
|
||||
const springed = spring(
|
||||
{ scroll: 0 },
|
||||
@ -12,6 +10,9 @@
|
||||
}
|
||||
)
|
||||
|
||||
let el: SVGElement
|
||||
let scroll = 0
|
||||
|
||||
function updateState(value: number) {
|
||||
const max = 359.99999
|
||||
const R = 50
|
||||
@ -25,8 +26,22 @@
|
||||
if (el) el.setAttribute('d', path)
|
||||
}
|
||||
|
||||
$: springed.set({ scroll: $scroll })
|
||||
function updateScroll() {
|
||||
const el = window.document.documentElement
|
||||
const percentage = el.scrollTop / (el.scrollHeight - el.clientHeight)
|
||||
scroll = isNaN(percentage) ? 0 : percentage
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const el = window.document
|
||||
el.addEventListener('scroll', updateScroll, false)
|
||||
updateScroll()
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateScroll)
|
||||
}
|
||||
})
|
||||
|
||||
$: springed.set({ scroll })
|
||||
$: updateState($springed.scroll)
|
||||
</script>
|
||||
|
||||
@ -34,12 +49,12 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" version="1.1">
|
||||
<path bind:this={el} fill="var(--clr-secondary)" d="" />
|
||||
</svg>
|
||||
<span>{$scroll.toFixed(2)}</span>
|
||||
<span>{scroll.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
right: 1em;
|
||||
pointer-events: none;
|
||||
@ -56,7 +71,5 @@
|
||||
border: 0.125em solid var(--clr-primary);
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
position: relative;
|
||||
top: 0.45em;
|
||||
}
|
||||
</style>
|
9
src/components/ReadingTime.astro
Normal file
9
src/components/ReadingTime.astro
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
import type { ReadTimeResults } from 'reading-time'
|
||||
|
||||
type Props = { readingTime: ReadTimeResults }
|
||||
|
||||
const { readingTime } = Astro.props
|
||||
---
|
||||
|
||||
<span>~ {readingTime.minutes.toFixed(0)} min</span>
|
@ -1,5 +1,11 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'svg'>
|
||||
---
|
||||
|
||||
<svg
|
||||
{...$$restProps}
|
||||
{...Astro.props}
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 1500 650"
|
||||
@ -13,12 +19,12 @@
|
||||
<path
|
||||
id="Niccolo-Outline"
|
||||
d="M20.271,459.439c-1.17,-3.419 3.175,-9.667 10.262,-9.656c47.47,-41.735 98.965,-93.306 153.09,-151.305l141.37,-151.839l46.857,-57.73c4.246,-5.313 12.634,-0.233 7.597,7.121c-24.452,33.091 -49.57,66.559 -72.909,99.021c-4.255,7.012 -28.702,40.073 -54.403,74.754c-31.401,44.917 -56.137,87.345 -72.031,126.472c-5.226,12.173 -10.453,24.346 -15.679,36.52c-18.953,41.143 -39.007,81.367 -59.994,120.815c42.202,-42.593 70.867,-72.932 102.804,-107.146c28.316,-30.334 61.042,-62.614 73.907,-73.637c10.762,-9.09 17.153,-13.711 20.76,-16.467c5.612,-4.288 13.108,-2.443 10.155,8.052c-3.103,9.01 -7.394,18.956 -13.682,30.475c-4.402,8.289 -8.249,16.961 -11.459,24.83c-4.646,11.389 -7.855,20.391 -7.923,25.916c14.003,-8.284 31.688,-21.532 51.119,-36.542c13.038,-8.455 26.163,-16.951 39.441,-25.517c12.498,-8.738 23.006,-15.921 36.217,-21.442c7.292,-2.43 13.246,1.576 10.104,8.202l-8.089,13.678c-6.343,7.45 -13.359,34.46 -11.387,34.695c3.534,0.777 7.149,0.17 10.794,-0.933c23.784,-10.493 45.573,-21.385 69.025,-34.661c6.369,-3.605 11.37,1.451 6.254,5.825c-16.222,12.959 -28.925,27.403 -34.865,44.701c8.792,0.261 20.532,-8.963 33.808,-16.713c8.014,-4.576 16.028,-9.154 20.885,-8.785c11.911,-10.996 25.507,-24.107 43.452,-42.675c13.197,-30.813 29.84,-58.163 48.859,-83.127c17.47,-25.723 37.654,-48.609 58.99,-70.29c22.847,-26.175 44.455,-44.727 71.493,-52.443c7.445,-2.124 16.358,3.673 17.086,14.754c0.351,4.574 0.445,8.416 0.463,12.046c-4.465,23.076 -37.053,52.117 -68.637,80.944c-40.543,34.913 -80.295,70.231 -118.776,106.201c-8.172,17.541 -14.392,35.163 -18.91,52.857c-0.517,3.438 -0.96,6.889 -0.507,10.481c1.697,1.017 5.468,0.45 9.67,-0.445c11.461,-4.152 26.727,-13.293 43.617,-24.563c4.248,-15.98 9.857,-31.063 16.469,-45.483c16.602,-29.693 44.252,-62.684 61.948,-68.886c11.646,-4.081 20.539,3.744 18.176,16.41c-5.323,22.443 -36.961,61.86 -79.496,102.518c3.018,1.49 5.795,0.679 9.36,-1.235c2.52,-0.936 3.644,0.943 2.567,2.551c-1.569,2.342 -2.793,4.242 -3.853,6.808c-4.713,11.41 -19.341,14.129 -25.345,4.372c-15.438,10.943 -30.287,20.032 -42.639,21.265c-13.619,2.275 -19.672,-1.519 -22.759,-7.695c-4.459,-8.734 -0.458,-24.537 8.084,-44.134c-10.253,10.639 -21.336,20.469 -33.584,29.164c-4.302,3.574 -8.532,3.089 -11.05,-1.904c-16.541,11.137 -30.907,20.415 -39.674,19.79c-14.821,-1.058 -15.256,-12.14 0.849,-34.251c-20.141,11.132 -35.112,21.67 -50.324,21.67c-12.367,-0 -16.07,-5.667 -16.095,-15.883c-0.026,-11.108 12.042,-28.307 25.69,-46.49c-6.766,1.251 -29.36,16.484 -36.856,22.894c-31.276,26.743 -56.376,48.181 -80.374,61.851c-28.635,14.765 -30.493,-3.213 -23.688,-22c5.204,-14.365 5.708,-14.079 16.705,-34.027c3.395,-6.157 12.513,-19.838 18.955,-32.346c-21.194,16.195 -63.494,61.438 -108.128,109.894c-54.127,64.575 -96.284,112.081 -119.345,130.945c-8.391,6.863 -21.32,0.204 -14.954,-12.912c30.64,-55.549 61.083,-111.714 91.044,-169.382c10.592,-24.866 23.464,-49.607 38.413,-74.233c15.015,-28.578 59.12,-90.218 110.565,-160.202l-112.922,123.021c-36.339,42.043 -74.641,81.815 -114.295,120.022l-34.998,26.716c-7.575,5.788 -15.046,5.237 -17.274,-1.272Zm584.135,-59.379c2.547,-13.05 7.342,-25.449 13.647,-37.411c13.317,-22.914 32.664,-47.218 46.624,-56.324c10.853,-5.711 15.959,-1.884 14.306,5.387c-14.859,30.917 -42.775,59.263 -74.577,88.348Zm-36.287,-54.812c18.429,-40.636 47.954,-79.719 82.452,-118.106c21.615,-24.592 43.824,-45.573 67.534,-57.425c8.454,-3.531 14.15,-3.27 15.663,2.74c1.208,5.511 1.087,12.919 -0.025,15.884c-10.895,29.06 -98.099,97.654 -165.624,156.907Z"
|
||||
/>
|
||||
></path>
|
||||
<path
|
||||
d="M790.827,340.284c14.131,-11.65 22.862,-18.483 26.192,-20.401c16.902,-8.922 34.857,-17.829 54.346,-26.712c41.304,-55.97 93.802,-108.542 154.102,-158.745c64.172,-53.215 105.987,-85.713 113.832,-89.283c6.351,-4.057 10.29,-3.836 9.835,2.373c0.537,5.883 -5.707,13.962 -14.222,22.776c-13.989,18.609 -69.137,70.564 -143.638,138.199c-16.254,14.232 -30.536,27.337 -42.56,39.151c-8.867,10.618 -18.587,21.162 -29.312,31.616c29.784,1.071 28.6,23.154 -0.625,46.65c-25.027,24.157 -54.137,45.113 -90.469,60.405c-28.265,41.837 -49.15,77.302 -60.555,104.586c-1.328,3.177 -7.815,18.804 -9.47,27.943c-4.9,27.063 10.015,23.06 21.109,17.971c16.709,-7.664 47.351,-35.697 78.978,-69.143c16.312,-20.342 32.626,-40.673 49.286,-59.531l35.791,-41.013c10.68,-10.696 15.887,-9.64 14.867,4.787l0.595,44.037c0.101,2.758 1.698,4.03 5.102,3.509c14.976,-4.162 29.386,-10.875 43.334,-19.664c3.561,-13.613 14.633,-25.34 29.175,-36.196l22.441,-14.931c18.8,-13.476 37.413,-21.894 55.74,-22.538c6.642,0.777 8.001,4.197 2.512,11.04c-8.132,9.079 -20.747,18.875 -34.694,28.883c-12.008,9.381 -32.042,22.63 -60.721,40.05c-4.734,3.027 -4.639,5.159 -0.543,6.55c30.233,-0.882 54.09,-16.005 77.861,-31.32l78.336,-53.796c6.813,-4.532 7.774,-2.56 4.78,4.683l-13.326,10.376c-11.237,9.236 -21.582,23.435 -30.556,45.247c19.972,-22.566 41.888,-43.948 65.1,-64.539c36.527,-31.836 66.455,-57.911 85.428,-72.972l49.659,-114.434c30.151,-70.206 63.115,-118.463 101.962,-120.814c10.2,-0.617 22.043,5.413 22.793,18.674c0.675,11.94 -9.22,36.621 -20.016,54.793c-17.775,39.721 -72.206,101.77 -143.423,162.579c-18.125,44.481 -37.315,87.848 -57.208,130.48c15.957,-15.122 32.068,-29.228 48.482,-41.326c16.178,-12.339 25.42,-11.829 23.443,9.463c-1.374,12.137 -3.975,23.706 -8.242,34.505c17.261,-20.861 34.969,-39.56 53.598,-53.805c22.833,-19.216 39.045,-22.42 43.464,2.893l0.687,19.386c0.61,3.885 2.56,3.346 4.64,0.295c3.65,-2.837 4.84,-1.296 3.571,3.234l-4.48,4.436c-8.095,6.965 -12.46,3.205 -13.005,-11.534l-2.491,-21.154c-1.277,-5.355 -4.898,-5.045 -9.637,-2.035c-10.262,6.903 -19.7,14.708 -28.47,23.249c-20.622,21.031 -39.722,43.404 -57.37,67.058c-6.958,7.296 -17.433,2.826 -13.117,-9.237c8.703,-18.489 16.414,-38.868 23.309,-60.801c0.513,-1.942 0.625,-3.57 0.555,-5.055c-0.084,-2.158 -1.304,-2.818 -3.997,-1.536c-10.007,4.648 -38.464,30.349 -69.307,58.775c-18.018,39.51 -42.493,81.084 -61.361,112.817c-15.895,26.735 -32.517,51.063 -51.844,77.582c-11.06,15.967 -24.357,29.563 -39.492,41.212c-14.64,9.765 -23.656,4.567 -23.983,-11.631c-0.227,-11.224 12.271,-31.246 29.841,-55.471l16.19,-27.055c37.737,-54.692 75.307,-98.044 112.733,-131.562l64.645,-136.834c-44.996,39.012 -86.331,78.465 -123.784,118.386c-10.374,15.05 -19.756,21.651 -27.489,14.2c-8.665,-7.056 3.682,-22.95 15.182,-38.487c-27.042,20.746 -52.866,39.881 -70.454,48.128c-18.019,9.664 -35.365,16.293 -51.281,16.483c-8.823,0.488 -14.584,-2.983 -17.154,-10.58c-16.049,9.662 -31.681,18.226 -44.512,19.401c-10.209,-0.192 -15.881,-3.907 -15.058,-12.664c1.616,-12.544 4.133,-25.363 7.168,-38.34c0.426,-5.95 0.285,-11.97 -0.325,-18.048c-4.409,1.154 -32.557,35.243 -62.642,72.018c-16.158,24.562 -42.094,53.85 -77.866,87.891c-29.099,26.823 -50.488,34.159 -63.639,23.277c-9.388,-7.768 -9.983,-23.902 -4.601,-44.68c14.14,-41.508 34.508,-77.887 57.319,-112.253l-15.554,0.957c-13.166,2.136 -21.228,-7.217 -20.125,-19.359c0.471,-5.185 5.926,-4.163 6.19,0.805l0.056,4.409c0.144,1.466 0.961,2.324 3.322,1.79c15.214,-2.886 27.175,-5.595 39.417,-8.32l11.731,-16.82c-12.537,7.756 -26.583,12.915 -43.06,13.89c-14.901,-0.493 -9.189,-15.08 6.979,-29.279Zm414.48,86.48c-22.256,40.512 -48.181,82.189 -76.975,126.343c-27.236,35.969 -44.649,56.694 -53.771,62.226c-0.12,-7.817 9.581,-24.513 23.178,-44.73c8.821,-14.641 16.901,-28.071 23.705,-39.421c40.639,-59.455 66.613,-90.413 83.863,-104.418Zm-178.28,-38.987c14.175,-9.07 28.957,-18.853 44.552,-29.591c19.866,-10.959 36.232,-20.597 40.815,-25.795c-27.737,2.819 -70.826,31.038 -85.367,55.386Zm-178.283,-30.729c31.942,-20.031 53.3,-35.788 65.798,-47.965c5.138,-4.37 9.49,-9.322 13.125,-14.804c-6.643,-1.412 -14.627,-1.157 -23.54,0.252c-9.2,9.223 -20.581,19.463 -33.337,30.345c-3.543,4.971 -11.183,15.301 -22.046,32.172Zm-58.64,-1.128c14.859,-1.459 34.671,-14.198 55.997,-28.34c3.655,-4.48 7.289,-8.929 9.624,-13.347c-9.978,3.571 -21.859,9.534 -34.242,16.306c-21.104,15.919 -32.258,23.973 -31.379,25.381Zm115.973,-78.403l33.33,-34.097c20.314,-19.226 41.291,-37.458 63.022,-54.56c31.836,-27.817 62.044,-54.745 89.268,-80.044c24.823,-25.326 53.39,-53.527 53.343,-59.749c-7.426,4.73 -16.767,13.03 -25.831,20.812c-21.766,19.158 -44.251,38.317 -66.962,57.475c-22.656,20.764 -45.415,42.327 -68.292,64.824c-33.48,32.148 -58.19,59.778 -77.878,85.339Zm401.363,-43.122c16.971,-45.011 35.077,-89.057 54.716,-131.801c17.33,-34.503 37.252,-62.015 61.735,-77.223c10.166,-5.541 19.875,-7.148 28.715,-1.277c6.635,6.635 4.298,14.28 2.519,21.567c-8.122,26.528 -25.987,56.266 -44.164,81.113c-25.536,34.905 -65.89,75.934 -103.521,107.621Z"
|
||||
/>
|
||||
></path>
|
||||
<path
|
||||
d="M1446.02,213.684c-2.16,-9.662 1.698,-13.133 10.952,-10.452c3.849,3.585 8.747,6.193 13.678,6.007c9.177,2.961 12.191,9.881 6.072,15.938c0.637,5.115 -5.038,5.379 -6.199,1.113c-13.591,0.532 -21.806,-3.846 -24.503,-12.606Z"
|
||||
/>
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.9 KiB |
39
src/components/SkillBar.astro
Normal file
39
src/components/SkillBar.astro
Normal file
@ -0,0 +1,39 @@
|
||||
---
|
||||
export type Props = {
|
||||
progress: number
|
||||
title: string
|
||||
}
|
||||
|
||||
const { progress, title } = Astro.props
|
||||
---
|
||||
|
||||
<div class="progress">
|
||||
<span>{title}</span>
|
||||
<div style={`width: ${progress * 100}%`}></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.progress {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: var(--clr-light);
|
||||
margin: 0.5em 0;
|
||||
padding: 0.1em 0.5em;
|
||||
border: 1px solid var(--clr-dark);
|
||||
}
|
||||
|
||||
.progress span {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.progress div {
|
||||
height: 100%;
|
||||
background: var(--clr-primary);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
export let letters: string = ''
|
||||
export let even = false
|
||||
export let readable = false
|
||||
</script>
|
||||
---
|
||||
export type Props = {
|
||||
letters: string
|
||||
even?: boolean
|
||||
readable?: boolean
|
||||
}
|
||||
|
||||
<div class:even class:readable>
|
||||
{#if even}
|
||||
{#each letters as letter}<span>{letter}</span>{/each}
|
||||
{:else}{letters}{/if}
|
||||
const { letters, readable = false, even = false } = Astro.props
|
||||
---
|
||||
|
||||
<div class:list={{ even, readable }}>
|
||||
{even ? [...letters].map((letter) => <span>{letter}</span>) : letters}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@ -18,7 +20,7 @@
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: min(8vw, 2.5em);
|
||||
font-size: min(8vw, 5em);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
letter-spacing: 0.35em;
|
||||
@ -31,6 +33,5 @@
|
||||
div.readable {
|
||||
letter-spacing: initial;
|
||||
text-transform: initial;
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
</style>
|
@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
export let name: string = ''
|
||||
export let href: string = ''
|
||||
export let count: number = 0
|
||||
</script>
|
||||
---
|
||||
export type Props = {
|
||||
name: string
|
||||
href: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
const { name, href, count } = Astro.props
|
||||
---
|
||||
|
||||
<a {href}>
|
||||
<div>
|
||||
{name} <i>{count}</i>
|
||||
{name}
|
||||
{count && <i>{count}</i>}
|
||||
</div>
|
||||
</a>
|
||||
|
36
src/components/Tags.astro
Normal file
36
src/components/Tags.astro
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
import Tag from './Tag.astro'
|
||||
|
||||
export type Props = {
|
||||
tags: ComponentProps<typeof Tag>[]
|
||||
rows?: number
|
||||
}
|
||||
|
||||
const { tags, rows = 1 } = Astro.props
|
||||
|
||||
const height = rows * 2
|
||||
---
|
||||
|
||||
<div style={`height: ${height}em;`}>
|
||||
{tags.map((tag) => <Tag {...tag} />)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: 0 -0.25rem;
|
||||
}
|
||||
|
||||
div::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
div {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
47
src/components/Toc.astro
Normal file
47
src/components/Toc.astro
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
import type { MarkdownHeading } from 'astro'
|
||||
|
||||
type Props = { headings: MarkdownHeading[] }
|
||||
|
||||
const { headings } = Astro.props
|
||||
---
|
||||
|
||||
<div class="toc">
|
||||
<b>Outline</b>
|
||||
{
|
||||
headings.map(({ slug, text, depth }) => (
|
||||
<div style={`margin-left: ${depth - 2}rem;`}>
|
||||
<span>▶</span>
|
||||
<a href={`#${slug}`}>{text}</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toc {
|
||||
margin: 2rem 0;
|
||||
|
||||
@media (min-width: 70rem) {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 45rem;
|
||||
width: calc(100vw - 50rem);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.5em;
|
||||
transform: translateY(-0.8em);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
a {
|
||||
height: 1.4em;
|
||||
display: inline-block;
|
||||
max-width: calc(100% - 1.5rem);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
5
src/consts.ts
Normal file
5
src/consts.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Place any global data in this file.
|
||||
// You can import this data from anywhere in your site by using the `import` keyword.
|
||||
|
||||
export const SITE_TITLE = 'Astro Blog';
|
||||
export const SITE_DESCRIPTION = 'Welcome to my website!';
|
232
src/content/blog/4-useful-typescript-tricks.md
Normal file
232
src/content/blog/4-useful-typescript-tricks.md
Normal file
@ -0,0 +1,232 @@
|
||||
---
|
||||
title: '5 useful Typescript tricks'
|
||||
date: '2019-10-06'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'tips-and-tricks'
|
||||
- 'typescript'
|
||||
coverImage: './images/amador-loureiro-BVyNlchWqzs-unsplash-scaled.jpg'
|
||||
---
|
||||
|
||||
Typescript is a godsend. It is very easy to get started with and for most developers there is no way back once they get the hang of it. Sometimes it can get pretty advanced and intimidating though.
|
||||
|
||||
This is why I decided to share 5 of my favourite typescript tips and tricks you might have needed in the past. Some are super basic, some are bit more advanced.
|
||||
|
||||
**Update** _07 Okt 2019 @ 07:53_
|
||||
Reddit user [jakeboone02](https://www.reddit.com/r/typescript/comments/de17xs/5_useful_typescript_tricks_small_tricks_you_might/f2t9prk?utm_source=share&utm_medium=web2x) found an error in the ternary code.
|
||||
|
||||
**Update** _06 Okt 2019 @ 15:06_
|
||||
Reddit user [smeijer87](https://www.reddit.com/r/typescript/comments/de17xs/5_useful_typescript_tricks_small_tricks_you_might/f2qveub?utm_source=share&utm_medium=web2x) found an error in the code for null coalescing.
|
||||
|
||||
**Update** _06 Okt 2019 @ 14:47_
|
||||
A fiendly reader pointed out excluding interface type are called discriminated unions.
|
||||
|
||||
1. [react higher-order components](#hoc)
|
||||
2. [smarter constructor](#constructors)
|
||||
3. [type checking functions](#type-checking-function)
|
||||
4. [discriminated unions](#excluding)
|
||||
5. [optional chaining & null coalescing](#future)
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/amador-loureiro-BVyNlchWqzs-unsplash-scaled.jpg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Photo by [Amador Loureiro](https://unsplash.com/@amadorloureiroblanco?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/search/photos/type?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
## Higher-order Components
|
||||
|
||||
In React [higher order components (HOC)](https://reactjs.org/docs/higher-order-components.html) are very useful tools. Generally they are used to wrap some layout or functionality to some other component. They are simply functions that return another component: basically the same pattern as decorators.
|
||||
|
||||
In typescript it can be confusing how to write them _maintaining the right props_ after wrapping the original component. Here you are:
|
||||
|
||||
```
|
||||
import React from 'react'
|
||||
|
||||
function withLayout<P extends object>(WrappedComponent: React.ComponentType<P>) {
|
||||
return (props: P) => (
|
||||
<div id='app'>
|
||||
<Header/>
|
||||
<WrappedComponent {...props}/>
|
||||
<Footer/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Note also that when using the `withLayout` you don't need to specify the generic type explicitly, as typescript will inherit from the function parameter. Super handy!
|
||||
|
||||
## Smarter constructors
|
||||
|
||||
Let's start by the building block this is based on. It's a basic Javascript trick, not a typescript exclusive at first.
|
||||
|
||||
```
|
||||
class Pizza {
|
||||
slices: number
|
||||
name: string
|
||||
|
||||
constructor(init) {
|
||||
Object.assign(this, init)
|
||||
}
|
||||
}
|
||||
|
||||
const pizza = new Pizza({
|
||||
slices: 8,
|
||||
name: 'Margherita',
|
||||
})
|
||||
```
|
||||
|
||||
What is happening here? With the super handy `Object.assign` simply assigns the object to the class. This is super handy when classes have many constructor parameters. But this is NOT type safe as your IDE/Editor will tell you. How do we fix this?
|
||||
|
||||
```
|
||||
import { NonFunctionKeys } from 'utility-types'
|
||||
|
||||
class Pizza {
|
||||
slices!: number
|
||||
name?: string
|
||||
|
||||
constructor(init: Pick<Pizza, NonFunctionKeys<Pizza>>) {
|
||||
Object.assign(this, init)
|
||||
}
|
||||
|
||||
eat() {
|
||||
this.slices = 0
|
||||
}
|
||||
}
|
||||
|
||||
const pizza = new Pizza({
|
||||
slices: 8,
|
||||
name: 'Margherita',
|
||||
})
|
||||
```
|
||||
|
||||
Let me explain what happens:
|
||||
This leverages the awesome [utility-types](https://github.com/piotrwitek/utility-types) package. We first take all the keys that are not a function, so we don't overwrite the `eat` method of the class. Then we pick those from the general Pizza type.
|
||||
|
||||
This means that `slices` will be required, while `name` will be optional, as they are defined.
|
||||
|
||||
## Type-checking Functions
|
||||
|
||||
Did you know you can write functions to tell typescript what type something is? This is awesome!
|
||||
|
||||
Suppose we have the following interfaces
|
||||
|
||||
```
|
||||
interface Food {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface Pasta extends Food {
|
||||
type: 'Spaghetti' | 'Fusilli'
|
||||
}
|
||||
|
||||
interface Pizza extends Food {
|
||||
slices: number
|
||||
}
|
||||
```
|
||||
|
||||
Now we could write a `cook` function that accepts both Pasta and Pizza. Typescript itself cannot differentiate between the too.
|
||||
|
||||
```
|
||||
function cook(what: Food) {
|
||||
if(what === Pizza) ????
|
||||
}
|
||||
```
|
||||
|
||||
Fortunately there is a nice solution built into typescript.
|
||||
|
||||
```
|
||||
function isPizza(x: Food | Pizza): x is Pizza {
|
||||
return x.hasOwnProperty('slices')
|
||||
}
|
||||
|
||||
function isPasta(x: Food | Pasta): x is Pasta {
|
||||
return x.hasOwnProperty('type')
|
||||
}
|
||||
|
||||
|
||||
function cook(plate: Food) {
|
||||
if (isPizza(plate)) {
|
||||
// Plate is now of type Pizza
|
||||
putInTheOven(plate)
|
||||
}
|
||||
if (isPasta(plate)) {
|
||||
// Plate is now of type Pasta
|
||||
putInThePan(plate)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here we define two functions that return `x is Sometype` and return a boolean value based on the input. It's up to you of course to define it properly, but this can be very useful in various situations.
|
||||
|
||||
## Discriminated unions
|
||||
|
||||
```
|
||||
type Sqlite = {
|
||||
type: 'sqlite',
|
||||
database: string,
|
||||
}
|
||||
|
||||
type PostgreSQL = {
|
||||
type: 'postgresql',
|
||||
database: string,
|
||||
host: string,
|
||||
post?: number
|
||||
}
|
||||
|
||||
type PossibleConfigs = Sqlite | PostgreSQL
|
||||
|
||||
function initialize(config: PossibleConfigs) {}
|
||||
```
|
||||
|
||||
This might look like a simple one, but I often see people putting those sorts of types all into the same interface. By separating the different type of objects you make sure that they are safe. Also the autocomplete will thank you.
|
||||
|
||||
## Optional Chaining & Null Coalescing
|
||||
|
||||
This are future features that will be introduced in Typescript 3.7 that are very useful and will not be lived without after the release in early November 2019.
|
||||
|
||||
Optional chaining is an obvious shorthand. Every time you need to check if a property (especially if nested) exists, you need to do lots of repetitive checking. No more!
|
||||
|
||||
```
|
||||
a && a.b && a.b.c // 🤬
|
||||
|
||||
a?.b?.c // 🚀
|
||||
```
|
||||
|
||||
Null coalescing is also a very useful shorthand. You all know the `||` shorthand, often used to initialise a variable if no value is given.
|
||||
|
||||
```
|
||||
const option = something || 'default'
|
||||
|
||||
// Sugar for
|
||||
const option = !!something ? something : 'default'
|
||||
```
|
||||
|
||||
The problem is with values that are actual values, but result as falsy.
|
||||
|
||||
```
|
||||
false || 'default' // => 'default'
|
||||
0 || 'default' // => 'default'
|
||||
```
|
||||
|
||||
This is where the Null Coalescing comes in.
|
||||
|
||||
```
|
||||
const option = something ?? 'default' // 🚀
|
||||
|
||||
// Sugar for
|
||||
const option = (x === null || x === undefined)
|
||||
? 'default'
|
||||
: x
|
||||
|
||||
0 ?? 'default' // => 0
|
||||
false ?? 'default' // => false
|
||||
```
|
||||
|
||||
Basically it only assign the default value if the provided one is `null` or `undefined` so that values like `false` or `0` don't get overwritten.
|
@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "5 JetBrains tips'n'tricks I wish I'd known sooner"
|
||||
date: '2019-07-03'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'ide'
|
||||
- 'jetbrains'
|
||||
coverImage: './images/cards-scaled.jpg'
|
||||
---
|
||||
|
||||
Here are some small features that may not be apparent to the newer devs that leverage the JetBrain IDEs. Most of them I discovered by using the.
|
||||
|
||||
1. Double Shift for navigating your codebase
|
||||
2. cmd/ctrl + shift + f for text search
|
||||
3. Remote Interpreters
|
||||
4. Syncing settings
|
||||
5. Reformatting
|
||||
|
||||
## Double Shift
|
||||
|
||||
For many including myself this is the primary way to navigate code and files. Simply press shift two times, type in the file, class or function you are searching for and press enter. This is by far the quickest and most accurate way to navigate code in any Editor or IDE I've tried so far.
|
||||
Do it once, and you will not go back.
|
||||
|
||||
## CMD + Shift + F
|
||||
|
||||
**Windows & Linux**: ctrl + shift + f
|
||||
|
||||
This is somewhat similar to 2x Shift. The main difference is that double shifts searches mainly for filenames and symbols (function names, class names, etc.) while CMD shift f functions more like a text search.
|
||||
|
||||
What makes this really powerful is that you can [regex search](https://www.jetbrains.com/help/idea/tutorial-finding-and-replacing-text-using-regular-expressions.html#Tutorial_Finding_and_Replacing_Text_Using_Regular_Expressions.xml), [mask files by extension](https://www.jetbrains.com/help/idea/finding-and-replacing-text-in-project.html#exclude_type), [exclude folders](https://www.jetbrains.com/help/webstorm/configuring-project-structure.html#022f3834) (e.g. build folders) and search only in specific directories.
|
||||
|
||||
If you ever _loose something in your code_ or _maybe your are new to the project_ and don't know where certain parts are located, **this is the way to find it.**
|
||||
|
||||
## Remote Interpreters
|
||||
|
||||
This is a huge one for me! JetBrains allows you tu run the code on remote machines. This extends also to all the packages you install, the shell in the terminal is automatically opened on the host.
|
||||
|
||||
Remote hosts can be either a machine in which you ssh into, a local docker container running a different version of the language that you need.
|
||||
|
||||
_You might ask why?_
|
||||
|
||||
1. Use a docker container with a specific version of node/python/php/etc. instead of installing it locally on your machine. Basically a virtual environment for every language. Amazing!
|
||||
2. Maybe you want to run the code on a Raspberry Pi, which has a different architecture. So all the packages you install will be installed on the Raspberry and when you hit _command+r_ the code will execute not on your machine but you still get the logs. Incredible!
|
||||
|
||||
To configure **simply go to the** _**run**_ **menu and add a new remote interpreter**.
|
||||
|
||||
## Sync Settings across devices
|
||||
|
||||
This is very simple. You can sync all your settings, including plugins, to either your JetBrain account or your own git settings repository. When you open the IDE some where else, everything is back to how it was.
|
||||
|
||||
Enable by going to: File -> Sync IDE Settings
|
||||
|
||||
**Note:** The synchronisation is per-IDE-basis, so your WebStorm settings are not synced with your PyCharm settings of course.
|
||||
|
||||
## Reformatting
|
||||
|
||||
Yet again a one of the reasons why I can't go back to VSCode. For each language there is a TON of customisation possible when reformatting. You can decide how your spaces should look, commas, imports, semicolons, everything is completely up to you.
|
||||
|
||||
You can tinker around with it in the Settings under: Editor -> Code Style -> <your language>
|
||||
|
||||
**Bonus:** If you select on a folder in the project view, you can reformat all files inside it, quick and easy. This is especially useful if one has imported some external sources for example.
|
||||
|
||||
Thats it, I hope you found some of it useful and that you can enjoy the JetBrain cosmos even more 😉
|
@ -0,0 +1,255 @@
|
||||
---
|
||||
title: 'A guide to Directus for Gatsby or Sapper as CMS'
|
||||
date: '2020-04-11'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'cms'
|
||||
- 'directus'
|
||||
- 'gatsby'
|
||||
- 'sapper'
|
||||
- 'static-generated'
|
||||
coverImage: './images/noah-silliman-doBrZnp_wqA-unsplash.jpg'
|
||||
---
|
||||
|
||||
For those who don't know what [Directus](https://directus.io/) is: an open source database first CMS that generates an api. Lot of buzzwords there, but it's truly a cool project that deserves much more attention IMO.
|
||||
|
||||
Recently I've used it to deliver some static generated websites that needed some sort of CMS. Think of a blog, or small landing pages. For that kind you can combine it with Gatsby or in this case Sapper to generate static html from the API.
|
||||
|
||||
The article will focus on Sapper, but the parts related to Directus are identical for Gatsby, just the frontend will change.
|
||||
|
||||
#### What will we do today?
|
||||
|
||||
1. [Install Directus](#1)
|
||||
2. [Create some data and make in publicly available](#2)
|
||||
3. [Create a super small frontend](#3)
|
||||
4. [Write a custom hook for Directus that automatically triggers the build whenever content changes in the DB.](#4)
|
||||
|
||||
## Installing Directus
|
||||
|
||||
This should be straight forward. These instructions are adopted from the [official docker guide](https://docs.directus.io/installation/docker.html). I will use Docker for this.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
version: '3.7'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:5.7
|
||||
volumes:
|
||||
- ./data/db:/var/lib/mysql
|
||||
env_file: .env
|
||||
|
||||
directus:
|
||||
image: directus/directus:v8-apache
|
||||
ports:
|
||||
- '8000:80'
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data/config:/var/directus/config
|
||||
- ./data/uploads:/var/directus/public/uploads
|
||||
```
|
||||
|
||||
The we run `docker-compose up -d`. After a few seconds we need to initialise Directus.
|
||||
|
||||
```bash
|
||||
docker-compose run directus install --email some@email.com --password 1337
|
||||
```
|
||||
|
||||
Now you can go to [localhost:8000](http://localhost:8000) and sign in with the credentials you just specified
|
||||
|
||||
## Create some data
|
||||
|
||||
Now I'm going to create some data to test our blog. First go to the [settings](http://localhost:8080/admin/#/_/settings/collections) and create a new collection. Im a going to call it `posts`.
|
||||
Then we are going to add a `title` text field and a simple markdown editor with a `body` field.
|
||||
Lastly we add a simple post with random data.
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/data.gif)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Insert collection and data
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
## Giving permissions
|
||||
|
||||
Now we need to give permission to the `public` role so that we don't need an API Key. For the most sites this is perfectly fine, since the data we only expose the data that gets displayed in the website anyways.
|
||||
|
||||
Goto the [roles settings](http://localhost:8080/admin/#/_/settings/roles) and click on `public`. There select the tables you want/need for the website.
|
||||
|
||||
Gotcha: If you have files (like photos) you also need to enable them for public viewing. Do this by clicking "Show Directus System Collections" and enabling view access to `Files`
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/permissions.gif)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Give permissions to the public user
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
## Building a minimal frontend with sapper
|
||||
|
||||
I will not explain how [Sapper](https://sapper.svelte.dev/) works as this is not the focus today. If you don't know Sapper: It's very similar to Nuxt or Next.js with the additional option to even export as static html, so the end result is similar to a Gatsby website. Very powerful and easy to use and code.
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
npx degit "sveltejs/sapper-template#rollup" my-blog
|
||||
cd my-blog
|
||||
yarn
|
||||
yarn run dev
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
### Load data from Directus
|
||||
|
||||
Directus has a [JS SDK](https://docs.directus.io/guides/js-sdk.html) and since we have made data public we don't even need a token or authentication. Awesome 🚀
|
||||
|
||||
```bash
|
||||
yarn add @directus/sdk-js
|
||||
```
|
||||
|
||||
First we are going to initialise the SDK. The default project name is simply `directus`
|
||||
|
||||
```ts
|
||||
// ./src/lib/api.js
|
||||
|
||||
import DirectusSDK from '@directus/sdk-js'
|
||||
|
||||
export const client = new DirectusSDK({
|
||||
url: 'http://localhost:8000',
|
||||
project: 'directus',
|
||||
})
|
||||
```
|
||||
|
||||
Then lets make a server side json loader so that the exported site will not even contact the server afterwards. Completely static html.
|
||||
|
||||
```ts
|
||||
// ./src/routes/posts.json.js
|
||||
|
||||
import { client } from '../lib/api'
|
||||
|
||||
export async function get(req, res, next) {
|
||||
try {
|
||||
const { data } = await client.getItems('posts')
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
res.end(JSON.stringify(data))
|
||||
} catch (e) {
|
||||
res.writeHead(404, {
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
message: 'Not found',
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Finally the svelte component.
|
||||
|
||||
```svelte
|
||||
// ./src/routes/index.svelte
|
||||
|
||||
<script context="module">
|
||||
export async function preload ({ params }) {
|
||||
const res = await this.fetch('posts.json')
|
||||
const data = await res.json()
|
||||
if (res.status === 200) return { data }
|
||||
else this.error(res.status, 'Not found')
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Post from '../components/Post.svelte'
|
||||
export let data
|
||||
</script>
|
||||
|
||||
<div class="wrapper">
|
||||
{#each data as post}
|
||||
<Post {post} />
|
||||
{/each}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Write a custom hook to trigger a build every time the data changes
|
||||
|
||||
When it comes to static generated sites often the easiest way to do things is to simply generate the site every "x" time. That kinda works however there will be many build that don't contain any change and you need to wait for a cron job to see changes. That sucks.
|
||||
|
||||
Fortunately Directus supports writing custom hooks! 🎉
|
||||
I will illustrate the case for [Drone](https://drone.io/), but the approach can be used for any CI/CD server out there.
|
||||
|
||||
For that we create a new php file and give it a name. In my case: `drone-hook.php`
|
||||
|
||||
```php
|
||||
# ./hooks/drone-hook.php
|
||||
|
||||
<?php
|
||||
|
||||
function process ($collection, $data) {
|
||||
$collectionsToWatch = ['posts'];
|
||||
if(!in_array($collection, $collectionsToWatch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, 'https://my.domain.com/api/repos/my-username/my-repo/builds');
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer '.$_ENV['DRONE_TOKEN'] ]);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER,true);
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
return [
|
||||
'actions' => [
|
||||
'item.create' => process,
|
||||
'item.update' => process,
|
||||
'item.delete' => process,
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
I've also put the token inside of the `.env` file so that I can safely check my code into a repo and not having to worry about having a token lying around in the codebase.
|
||||
|
||||
```bash
|
||||
# .env
|
||||
|
||||
...
|
||||
DIRECTUS_DATABASE_PASSWORD=directus
|
||||
|
||||
DRONE_TOKEN=my-drone-token
|
||||
```
|
||||
|
||||
The last thing to do is actually load the code into Directus. You can simply mount the `./hooks` folder we just created into the container and reload.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
version: "3.7"
|
||||
|
||||
...
|
||||
|
||||
directus:
|
||||
...
|
||||
volumes:
|
||||
...
|
||||
- ./hooks:/var/directus/public/extensions/custom/hooks
|
||||
```
|
||||
|
||||
This will trigger a curl post request every time items in a collection listed inside of `$collectionsToWatch` get either created, updated, or deleted.
|
||||
|
||||
You will probably need to make some adaptations if you are not using Drone, but at the end it will boil down to making a http request to your build server triggering a new build.
|
290
src/content/blog/a-practical-introduction-to-react-hooks.md
Normal file
290
src/content/blog/a-practical-introduction-to-react-hooks.md
Normal file
@ -0,0 +1,290 @@
|
||||
---
|
||||
title: 'A practical introduction to React Hooks'
|
||||
date: '2019-05-03'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'hooks'
|
||||
- 'javascript'
|
||||
- 'react'
|
||||
coverImage: './images/matt-artz-353210-unsplash-scaled.jpg'
|
||||
---
|
||||
|
||||
Since [React](https://reactjs.org/) 16.8 was published in February Hooks are now officially supported and the API finalised and stable. They arose around the idea of functional programming. In short: they allow to have state in functional components and with custom hooks (we'll have a look at those later) they allow us to reuse and share state logic between multiple components. This article assumes a basic understanding of React.
|
||||
|
||||
All the code shown can be found here: [https://git.nicco.io/cupcakearmy/guide-react-hooks](https://git.nicco.io/cupcakearmy/guide-react-hooks)
|
||||
|
||||
#### What we will look at today
|
||||
|
||||
1. Class Components vs Functional Components
|
||||
2. Native React hooks
|
||||
- `useState`
|
||||
- `useEffect`
|
||||
- `useRef`
|
||||
3. Custom hooks
|
||||
- `useWindow`
|
||||
- `useApi` (The real power)
|
||||
|
||||
## 1\. Class vs Functional
|
||||
|
||||
Let's first have a look at the 'hello world' of react: A simple counter which we can increment or decrement.
|
||||
|
||||
###### Class
|
||||
|
||||
```
|
||||
import React from 'react'
|
||||
|
||||
|
||||
class SimpleClass extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
counter: 0,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
console.log('Lets goo 🚀')
|
||||
setTimeout(() => this.setState({ counter: 5 }), 2000)
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
console.log(this.state.counter)
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<div>{this.state.counter}</div>
|
||||
<br/>
|
||||
<button onClick={() => this.setState({ counter: this.state.counter - 1 })}>Decrease</button>
|
||||
<button onClick={() => this.setState({ counter: this.state.counter + 1 })}>Increase</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Easy! Now we will convert the snippet above to the functional equivalent with the help of hooks.
|
||||
|
||||
###### Hooks
|
||||
|
||||
```
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
|
||||
const SimpleFC = () => {
|
||||
const [counter, setCounter] = useState(0)
|
||||
|
||||
return <div>
|
||||
<div>{counter}</div>
|
||||
<br/>
|
||||
<button onClick={() => setCounter(counter - 1)}>Decrease</button>
|
||||
<button onClick={() => setCounter(counter + 1)}>Increase</button>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
Awesome 🚀 Simple enough right?
|
||||
|
||||
## 2\. Native React hooks
|
||||
|
||||
### useState
|
||||
|
||||
Our constructor with `state` is gone and we have a simple `const [counter, setCounter] = useState(0)`.
|
||||
|
||||
How does this work? `useState` returns an array wich deconstructed gives us a getter and a setter. The parameter we pass to it is the initial value. That is all. Simple and useful.
|
||||
|
||||
### useEffect
|
||||
|
||||
How about the timeout and the `console.log`? Welcome `useEffect`!
|
||||
|
||||
`useEffect` takes a function and executes it every time the component updates. So it is basically `componentDidMount` and `componentDidUpdate` together.
|
||||
The second parameter determines when the function will be triggered. It expects an array and checks whether the variables inside it change.
|
||||
If no array is passed it will trigger every time the component gets updated and or mounted.
|
||||
This means that you can pass props into the array and it will effect only when those change. Also, if you pass an empty array it will trigger only once and is equivalent to `componentDidMount`.
|
||||
|
||||
```
|
||||
useEffect(myFN) // triggered every time the component gets updated
|
||||
useEffect(myFN, []) // Only triggered
|
||||
useEffect(myFN, [prop1, prop2]) // Gets triggered when either the props get changed
|
||||
```
|
||||
|
||||
In our example from above we would use it as follows:
|
||||
|
||||
```
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
|
||||
const SimpleFC = () => {
|
||||
const [counter, setCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setCounter(5)
|
||||
}, 1000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log(counter)
|
||||
})
|
||||
|
||||
return <div>
|
||||
<div>{counter}</div>
|
||||
<br/>
|
||||
<button onClick={() => setCounter(counter - 1)}>Decrease</button>
|
||||
<button onClick={() => setCounter(counter + 1)}>Increase</button>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
### useRef
|
||||
|
||||
Now let's have a look at `useRef`. We will have a normal class based component and the equivalent functional one with the help of hooks.
|
||||
|
||||
###### Class
|
||||
|
||||
```
|
||||
class RefClass extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.myRef = React.createRef()
|
||||
this.change = this.change.bind(this)
|
||||
}
|
||||
|
||||
change() {
|
||||
this.myRef.current.style.backgroundColor = '#6ba7ee'
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div>
|
||||
<button onClick={this.change}>Change Me</button>
|
||||
<br/><br/>
|
||||
<div ref={this.myRef} style={{ width: 50, height: 50, backgroundColor: '#000000' }}/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
###### Hooks
|
||||
|
||||
```
|
||||
const RefFN = () => {
|
||||
const rect = useRef()
|
||||
|
||||
const change = () => rect.current.style.backgroundColor = '#6ba7ee'
|
||||
|
||||
return <div>
|
||||
<button onClick={change}>Change Me</button>
|
||||
<br/><br/>
|
||||
<div ref={rect} style={{ width: 50, height: 50, backgroundColor: '#000000' }}/>
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
That is huge improvement in terms of amount code and most importantly readability. `react.current` points to the Dom element, which we can then modify at our will.
|
||||
|
||||
As a side node: look how much cleaner we can have class functions. instead of needing binding the function to `this`, in functional components we just need to define them.
|
||||
|
||||
## 3\. Custom hooks
|
||||
|
||||
This is where the real power lies. With custom hooks react allows you to reuse stateful logic and share it between components. Very powerful.
|
||||
We will cover two examples:
|
||||
|
||||
1. Window size
|
||||
2. Consume an API
|
||||
|
||||
### Window size
|
||||
|
||||
Assume you want to make a component dependent on the window size of the browser. With react hooks this is quick, easy and reusable.
|
||||
|
||||
###### hooks.js
|
||||
|
||||
```
|
||||
export const useWindowSize = () => {
|
||||
const getCurrentSize = () => ({ height: window.innerHeight, width: window.innerWidth })
|
||||
|
||||
const [size, setSize] = useState(getCurrentSize())
|
||||
|
||||
useEffect(() => {
|
||||
const handle = () => setSize(getCurrentSize())
|
||||
window.addEventListener('resize', handle)
|
||||
return () => window.removeEventListener('resize', handle)
|
||||
})
|
||||
|
||||
return size
|
||||
}
|
||||
```
|
||||
|
||||
###### component.jsx
|
||||
|
||||
```
|
||||
import { useWindowSize } from '../Hooks'
|
||||
|
||||
const Custom = ()=> {
|
||||
const size = useWindowSize()
|
||||
|
||||
return <div>
|
||||
Width: {size.width}
|
||||
<br/>
|
||||
Height: {size.height}
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
As we can see we created a custom hook called `useWindowSize`. We now can use our own hook inside of other components.
|
||||
|
||||
Custom components are just arrow functions that use the native `useState` and `useEffect` and some custom logic you add.
|
||||
|
||||
Note the `return () => window.removeEventListener('resize', handle)` inside the effect function. You can return a function in the effect function that will get called whenever the hook will be unmounted. This allows us to do cleanup. In this case we stop listening for window size changes. Neat 💪
|
||||
|
||||
### API Hook
|
||||
|
||||
Last but definitely not least: API calls. I personally think this is where hooks really show their power. I'll show you the code first and then explain.
|
||||
|
||||
###### hooks.js
|
||||
|
||||
```
|
||||
export const useCallApi = (url) => {
|
||||
const [data, setData] = useState()
|
||||
|
||||
const update = () => {
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(json => setData(json))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
update()
|
||||
}, [])
|
||||
|
||||
return [data, update]
|
||||
}
|
||||
```
|
||||
|
||||
###### posts.jsx
|
||||
|
||||
```
|
||||
import { useCallApi } from '../Hooks'
|
||||
|
||||
|
||||
const Posts = () => {
|
||||
const [posts] = useCallApi(`https://jsonplaceholder.typicode.com/posts`)
|
||||
const [users] = useCallApi(`https://jsonplaceholder.typicode.com/users`)
|
||||
// ...
|
||||
|
||||
if (!posts) return <div>Loading 🕰</div>
|
||||
|
||||
return <div>
|
||||
{posts.map((post, i) => <div key={i}>
|
||||
<h3>{post.title}</h3>
|
||||
<p>{post.body}</p>
|
||||
</div>)}
|
||||
</div>
|
||||
}
|
||||
```
|
||||
|
||||
What is happening? We created a custom hook that queries an API and returns the result. How? We pass a url to the hook and we get the data back.
|
||||
|
||||
Internally the hook uses `useState` to save the results. It executes the update functions once (because the use effect has an empty array as second parameter).
|
||||
|
||||
Now we can use the `useCallApi` hook in multiple components or many times inside the same component. Options are endless.
|
@ -0,0 +1,187 @@
|
||||
---
|
||||
title: 'A sane and efficient guide for consuming GraphQL endpoints in Typescript'
|
||||
date: '2021-12-31'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'code-generation'
|
||||
- 'graphql'
|
||||
- 'typescript'
|
||||
coverImage: './images/clayton-robbins-Ru09fQONJWo-unsplash-scaled.jpg'
|
||||
---
|
||||
|
||||
GraphQL is becoming common practice in the wild, while I feel the workflow with Typescript is still not straight forward. I want to propose one way to go about it and hopefully make your next Typescript GraphQL project a joy to work with!
|
||||
Lets dive deeper 🤿.
|
||||
|
||||
I created a tiny [companion repository](https://github.com/cupcakearmy/blog-typescript-graphql) if you want to check out the code and try it out.
|
||||
Or check out the [finished demo](https://blog-typescript-graphql.vercel.app/).
|
||||
|
||||
## Intro
|
||||
|
||||
First we need to decide on what do we want (and probably need)
|
||||
|
||||
- Editor support for syntax highlighting `gql` and `.graphql` files.
|
||||
- Strict type safety for our client.
|
||||
- Easy tooling & workflow
|
||||
|
||||
So our workflow will look something like this:
|
||||
|
||||
```
|
||||
GrapQL API -> Schema -> Queries & Mutations -> Typescript -> Client
|
||||
```
|
||||
|
||||
For this article we'll build a minuscule one pager using the [SpaceX Land GraphQL API](https://api.spacex.land/graphql/) to display some space travel data.
|
||||
|
||||
## Editor setup
|
||||
|
||||
The setup will be be for VSCode. For that we first install the [GraphQL extension](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql). This will enable us to have warnings and autocompletion inside of `gql` tags and `.graphql` files.
|
||||
|
||||
We need to add a `.graphqlrc.yml` file at the root with the following content:
|
||||
|
||||
```yaml
|
||||
schema: https://api.spacex.land/graphql/
|
||||
```
|
||||
|
||||
## Writing Queries & Mutations
|
||||
|
||||
Now onto the real stuff.
|
||||
We want to take our endpoint, generate types and queries from it that can then be used by Typescript safely. To do that we will:
|
||||
|
||||
1. Setup generators for Schema, Queries, Mutations & SDK.
|
||||
2. Write some Queries & Mutations
|
||||
3. Generate the SDK
|
||||
4. Consume the SDK
|
||||
|
||||
### Setup
|
||||
|
||||
There is this amazing project called `@graphql-codegen` which is a collection of tools for helping you generating various things from GraphQL. Let's install:
|
||||
|
||||
```bash
|
||||
# Generators
|
||||
pnpm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-graphql-request
|
||||
# For the SDK
|
||||
pnpm i -D graphql graphql-request graphql-tag
|
||||
```
|
||||
|
||||
I will assume my GraphQL stuff will live under `./src/lib/gql`
|
||||
|
||||
We will create a top level configuration file to handle all of our generation step called `codegen.yaml`. Ignore the `config` option for now, I will explain that later
|
||||
|
||||
```yaml
|
||||
schema: https://api.spacex.land/graphql/
|
||||
documents: 'src/**/*.graphql'
|
||||
generates:
|
||||
./src/lib/gql/gen.ts:
|
||||
plugins:
|
||||
- '@graphql-codegen/typescript'
|
||||
- '@graphql-codegen/typescript-operations'
|
||||
- '@graphql-codegen/typescript-graphql-request'
|
||||
config:
|
||||
maybeValue: 'T'
|
||||
typesPrefix: GQL
|
||||
immutableTypes: true
|
||||
useTypeImports: true
|
||||
avoidOptionals: true
|
||||
```
|
||||
|
||||
The property `schema` does not need an explanation.
|
||||
`generates` has 3 plugin enabled, one for the general types, another for queries and mutations and the last one to generate us a ready to use SDK and will save it under `./src/lib/gql/gen.ts`.
|
||||
`documents` is a glob that will find all our GraphQL files we write and generate the according code.
|
||||
|
||||
### Creating Queries
|
||||
|
||||
Now let's create a `src/lib/gql/root.graphql` file and write some queries, all autocompleted of course!
|
||||
|
||||
```gql
|
||||
query LaunchpadsMany {
|
||||
launchpads(limit: 10) {
|
||||
id
|
||||
name
|
||||
location {
|
||||
name
|
||||
}
|
||||
successful_launches
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
query LaunchByYear($year: String!) {
|
||||
launches(find: { launch_year: $year }) {
|
||||
mission_id
|
||||
mission_name
|
||||
launch_date_utc
|
||||
rocket {
|
||||
rocket_name
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Let magic do it's thing
|
||||
|
||||
```bash
|
||||
pnpm exec graphql-codegen
|
||||
```
|
||||
|
||||
This will look at all our custom queries and mutations and generate us a ready to consume SDK that is completely typed. Amazing!
|
||||
|
||||
### Leverage the new SDK
|
||||
|
||||
```ts
|
||||
// src/lib/gql/index.ts
|
||||
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import { getSdk } from './gen'
|
||||
|
||||
const client = new GraphQLClient('https://api.spacex.land/graphql/')
|
||||
export const SDK = getSdk(client)
|
||||
```
|
||||
|
||||
```ts
|
||||
import { SDK } from '$lib/gql'
|
||||
|
||||
const data = await SDK.LaunchByYear({ year: '2021' })
|
||||
```
|
||||
|
||||
You can also use the generated types to explicitly set them
|
||||
|
||||
```ts
|
||||
import { SDK } from '$lib/gql'
|
||||
import type { GQLLaunchByYearQuery } from '$lib/gql/gen'
|
||||
|
||||
const data: GQLLaunchByYearQuery = await SDK.LaunchByYear({ year: '2021' })
|
||||
```
|
||||
|
||||
Every thing is typed now, I can't pass a number to the `year` variable or use return data that does not exist. Typescript will error on me. This not only gives us autocompletion but also the safety of what we are doing.
|
||||
|
||||
### Configuration options
|
||||
|
||||
I promised I would come back to it at some point.
|
||||
|
||||
```yaml
|
||||
schema: ...
|
||||
generates:
|
||||
...
|
||||
config:
|
||||
maybeValue: "T"
|
||||
typesPrefix: GQL
|
||||
immutableTypes: true
|
||||
useTypeImports: true
|
||||
avoidOptionals: true
|
||||
```
|
||||
|
||||
There are [many options](https://www.graphql-code-generator.com/plugins/typescript#config-api-reference) for the generators, but I think these are quite sensible defaults.
|
||||
|
||||
`maybeValue` is `T | null` as default, but since we only use our queries which are type safe we can just remove uncertainty and use the correct type straight away.
|
||||
|
||||
`avoidOptionals` same thing as the `maybeValue`, just with `prop:?`. Don't want that.
|
||||
|
||||
`typesPrefix` is useful if you have some own type specifications that you don't want to clash with. I like to prefix all my generated GraphQL stuff with `GQL` to keep it tidy.
|
||||
|
||||
`immutableTypes` i prefer using an immutable type, with basically adds a `readonly` to every property. This way we are sure we are not editing data on the client.
|
||||
|
||||
`useTypeImports` this uses `import type` whenever possible.
|
||||
|
||||
## Final thoughts
|
||||
|
||||
I hope this made your GraphQL life a bit easier, it definitely did for me and it's way more fun to consume GraphQL API this way. Also something worth mentioning is that you can use the `@graphql-codegen/typescript-generic-sdk` package instead of the `@graphql-codegen/typescript-graphql-request` if you want to do the network requests yourself. It's easy to use but if you don't really have a reason just stick with the `graphql-request` one I'd say.
|
123
src/content/blog/automate-github-releases-with-drone.md
Normal file
123
src/content/blog/automate-github-releases-with-drone.md
Normal file
@ -0,0 +1,123 @@
|
||||
---
|
||||
title: 'Automate Github releases with Drone.'
|
||||
date: '2020-01-29'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'cd'
|
||||
- 'drone'
|
||||
coverImage: './images/franck-v-U3sOwViXhkY-unsplash-scaled-1.jpg'
|
||||
---
|
||||
|
||||
If you have a project on github that has releases for code or binaries for example it might be a good idea to automate it. Not only this saved a lot of clicks and time, but also it makes releases predictable and therefore less prone to errors in the process.
|
||||
|
||||
For this article I will take my own [project](https://github.com/cupcakearmy/autorestic) as the example here, but of course this can be applied to any project, written in whatever language and/or framework.
|
||||
|
||||
Also I will base this guide on [Drone](https://drone.io/). But I'm sure there is the same workflow for jenkins/circle/whatever CI/CD system you are using.
|
||||
This means I'm assuming you have a repository already running with Drone.
|
||||
|
||||
The first thing we will need is an access token for the Github API.
|
||||
You can get them here [https://github.com/settings/tokens](https://github.com/settings/tokens). I called my `Drone` and you need to check the permissions for the repos as follows.
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/Screenshot-2020-01-29-at-14.57.05.png)
|
||||
|
||||
<figcaption>
|
||||
|
||||
How to create a new token in Github
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
Copy the token and save it somewhere **safe**. You will see it only once.
|
||||
|
||||
We will add this token to our Drone repository settings. For that navigate to your drone instance and open the settings for the repository in question.
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/Screenshot-2020-01-29-at-14.55.28.png)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Add the token to Drone secrets
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
I've called my secret `github` and I have not allowed it in PRs. Otherwise a PR made by some random user could trigger a release. We don't want that.
|
||||
|
||||
Now it's time to edit our drone file and make everything automatic. The flow at the end will be as follows.
|
||||
|
||||
1. Code, commit and develop
|
||||
2. When you are ready for the next release we create a tag
|
||||
3. Once a tag is created and pushed drone will automatically build and release that code attached to the tag.
|
||||
|
||||
Simple right? Lets see how!
|
||||
|
||||
```yaml
|
||||
# .drone.yml
|
||||
---
|
||||
kind: pipeline
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: node
|
||||
pull: always
|
||||
commands:
|
||||
- yarn
|
||||
- yarn run bin
|
||||
when:
|
||||
event: tag
|
||||
|
||||
- name: publish
|
||||
image: plugins/github-release
|
||||
pull: always
|
||||
settings:
|
||||
api_key:
|
||||
from_secret: github
|
||||
files: bin/*
|
||||
checksum:
|
||||
- sha512
|
||||
note: CHANGELOG.md
|
||||
when:
|
||||
event: tag
|
||||
---
|
||||
kind: signature
|
||||
hmac: 3b1f235f6a6f0ee1aa3f572d0833c4f0eec931dbe0378f31b9efa336a7462912
|
||||
```
|
||||
|
||||
Lets understand what is happening here:
|
||||
|
||||
First I'm building my project. In this case this is a standalone typescript executable build by [pkg](https://github.com/zeit/pkg). The build binaries will be emitted into the `./bin` folder. But it really does not matter. Could be anything.
|
||||
|
||||
Secondly we tell the [Github release plugin](http://plugins.drone.io/drone-plugins/drone-github-release/) what files we want to include in the release. In my case this was everything inside the `bin` folder. This can also be an array.
|
||||
|
||||
```
|
||||
files:
|
||||
- dist/*
|
||||
- bin/binary.exe
|
||||
```
|
||||
|
||||
The `api_key` includes the token, which we load from a secret so that we don't simply put in the `.drone.yml` file, which could be a huge security issue!
|
||||
|
||||
The `checksum` setting is also amazing because as the name suggests the plugin automatically generates checksums for all the files. That is amazingly practical and there is no reason not to do that. You can choose a few hash functions but I would suggest simply going with `sha512`.
|
||||
|
||||
## So how do a trigger a release now?
|
||||
|
||||
Simple! First tag your code with the following command
|
||||
|
||||
```bash
|
||||
git tag 1.2.3
|
||||
```
|
||||
|
||||
Now push the tag and drone will be on its way
|
||||
|
||||
```bash
|
||||
git push --tags
|
||||
```
|
||||
|
||||
Thats it! Hope it made your release journey easier 🙂
|
@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 'Backup MongoDB inside of Docker the easy way'
|
||||
date: '2019-08-15'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'cli'
|
||||
- 'docker'
|
||||
coverImage: './images/tobias-fischer-PkbZahEG2Ng-unsplash-scaled.jpg'
|
||||
---
|
||||
|
||||
Backing up a mongo instance is more confusing than it should be. Maybe you have run into a `the input device is not a TTY` or you simply don't know how to do it? Here are two 1-Liner to backup and restore a running mongo instance.
|
||||
|
||||
## Setup
|
||||
|
||||
First we define our mongo instance like below. Notice that instead of mapping the data directory onto our filesystem we have a native volume.
|
||||
|
||||
###### docker-compose.yml
|
||||
|
||||
```
|
||||
version: '3.7'
|
||||
|
||||
volumes:
|
||||
db:
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mongo:3-xenial
|
||||
restart: always
|
||||
volumes:
|
||||
- db:/data/db
|
||||
ports:
|
||||
- 27017:27017
|
||||
```
|
||||
|
||||
Then start with `docker-compose up -d`.
|
||||
|
||||
## Backup
|
||||
|
||||
First we will do a backup of our running instance.
|
||||
|
||||
```
|
||||
docker-compose exec -T db mongodump --archive --gzip --db mydb > dump.gz
|
||||
```
|
||||
|
||||
The `-T` option is for enabling piping the output to our own machine. We also tell mongo to use the `--gzip` option to compress the file significantly.
|
||||
Lastly we specify the `--db <database>` that we want to backup.
|
||||
|
||||
## Restore
|
||||
|
||||
Whenever we want to restore a db, or maybe seed it we can run the following:
|
||||
|
||||
```
|
||||
docker-compose exec -T db mongorestore --archive --gzip < dump.gz
|
||||
```
|
87
src/content/blog/be-your-own-tiny-image-cdn.md
Normal file
87
src/content/blog/be-your-own-tiny-image-cdn.md
Normal file
@ -0,0 +1,87 @@
|
||||
---
|
||||
title: 'Be your own (tiny) image CDN'
|
||||
date: '2023-04-28'
|
||||
coverImage: './images/meagan-carsience-QGnm_F_nd1E-unsplash1.jpg'
|
||||
---
|
||||
|
||||
Today, I want to share how to create and host your own image transformation service, much like the known [Imgix](https://imgix.com/) and [Cloudinary](https://cloudinary.com/). The aim is to have a powerful transformation server for images that caches, so images only need to be computed once.
|
||||
|
||||
The building blocks will be [imgproxy](https://github.com/imgproxy/imgproxy) and [nginx](https://nginx.org/). The former is a battle tested and fast image server with support for most image operations, while nginx should not need an introduction.
|
||||
|
||||
While imgproxy is the core of this operation, it does not support caching. This is intentional, as it's intended to be run behind a proxy. For that, nginx is the tool of choice, as it enables us to easily setup caching rules to avoid generating the same image twice in a given cache interval. Everything will be done in docker containers, but the concept, of course, extends to bare metal too.
|
||||
|
||||
## Setup
|
||||
|
||||
Imgproxy fortunately is very customisable and options can be passed by env variables, which is wonderful.
|
||||
|
||||
It's generally advised to use signed URLs if possible. In my case, there was no backbend that could sign them, so it was avoided. Whenever omitting signing, it is critical to limit the allowed sources to the minimum with `IMGPROXY_ALLOWED_SOURCES` so that it cannot be abused by other websites.
|
||||
|
||||
Below is docker file used. Required is only the `IMGPROXY_BIND` as otherwise nginx cannot connect to our image container. The other options are up to you and are just here for a quick setup.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yaml
|
||||
version: '3.8'
|
||||
|
||||
volumes:
|
||||
cache:
|
||||
|
||||
services:
|
||||
img:
|
||||
image: darthsim/imgproxy
|
||||
environment:
|
||||
# Required for nginx
|
||||
IMGPROXY_BIND: 0.0.0.0:80
|
||||
|
||||
# Security
|
||||
IMGPROXY_MAX_SRC_RESOLUTION: 100
|
||||
IMGPROXY_ALLOWED_SOURCES: https://images.example.org/
|
||||
|
||||
# Transforms
|
||||
IMGPROXY_ENFORCE_WEBP: true
|
||||
IMGPROXY_ENFORCE_AVIF: true
|
||||
IMGPROXY_ONLY_PRESETS: true
|
||||
IMGPROXY_PRESETS: default=resizing_type:fit,sm=size:250:250,md=size:500:500,lg=size:1000:1000
|
||||
|
||||
proxy:
|
||||
image: nginx
|
||||
ports:
|
||||
- 80:80
|
||||
volumes:
|
||||
- ./proxy.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- cache:/tmp
|
||||
```
|
||||
|
||||
The more interesting part is the nginx configuration file below. In this case, we target 30 days as a cache TTL. This could be easily increased if we are only talking about static images.
|
||||
|
||||
```nginx
|
||||
# Set cache to 30 days, 1GB.
|
||||
# Only use the uri as the cache key, as it's the only input for imageproxy.
|
||||
proxy_cache_path /tmp levels=1:2 keys_zone=images:8m max_size=1g inactive=30d;
|
||||
proxy_cache_key "$uri";
|
||||
proxy_cache_valid 200 30d;
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /
|
||||
{
|
||||
proxy_pass_request_headers off;
|
||||
proxy_set_header HOST $host;
|
||||
proxy_set_header Accept $http_accept;
|
||||
|
||||
proxy_pass http://img;
|
||||
|
||||
proxy_cache images;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Here we are configuring a few things, so let's elaborate:
|
||||
|
||||
First a cache is configured at the location `/tmp`, with the name `images`, a maximum size of 1 gigabyte and the `inactive` parameter to 30 days.
|
||||
For the cache key, we use only the `$uri` variable, as all the parameters that affect image generation are included in the path and makes therefore the image transformation unique.
|
||||
Lastly, we tell nginx to cache all responses with code `200` for 30 days.
|
||||
|
||||
Another important trick is to strip all headers that reach the proxy. This is done by setting `proxy_pass_request_headers` and only passing the `Accept` header, as it's required for automatically determining the image format.
|
@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 'Cleanup downloaded Google Photos Takeout archives'
|
||||
date: '2019-05-04'
|
||||
categories:
|
||||
- 'general'
|
||||
tags:
|
||||
- 'google-photos'
|
||||
- 'google-takeout'
|
||||
- 'icloud-photos'
|
||||
- 'migration'
|
||||
coverImage: './images/rayan-almuslem-1302778-unsplash-scaled.jpg'
|
||||
---
|
||||
|
||||
Recently I've been taking my tin foil hat a bit more seriously and since I mostly live in the Apple ecosystem (yes, you can judge me) the iCloud Photos felt like a pretty good alternative. Yes, it's still a cloud but the content [is encrypted](https://support.apple.com/en-us/HT202303) and most importantly Apple has no real economical incentive on data mining you data. They are far ahead in terms of privacy. With that out of the way let's go! 🚀
|
||||
|
||||
TLDR: I wrote this [cleaning script](https://gist.github.com/CupCakeArmy/51070b311e6fd0a3f2d793bee3350ede) (tested only on macOS) to remove all duplicates from the [Google Takeout](https://takeout.google.com/) folders.
|
||||
|
||||
The process seemed easy at first. Google offers an awesome tools for exporting data out of their servers. It's called [Takeout](https://takeout.google.com/). So basically you select the Google Photos service and let them create the archives. Then after a few hours you can download them.
|
||||
|
||||
Now at the time I had ~40gb worth of pictures and videos saved in Googles cloud, however the archives I downloaded where about ~90gb. I started looking into it and a lot of photos where duplicates and edited versions that google was keeping. In addition the folders where full of JSON metadata.
|
||||
|
||||
**time for cleanup 🧹**
|
||||
|
||||
Fortunately for us there is the awesome `find` command that will save our lives. Removing metadata and duplicates from more than ~50k files is impossible by hand.
|
||||
|
||||
First we need to remove all `.json` files:
|
||||
|
||||
```
|
||||
find ./my_takeout_folder -name "*.json" -type f -delete
|
||||
```
|
||||
|
||||
Then all the duplicates that contain a `(1)` at the end of the file.
|
||||
|
||||
```
|
||||
# macOS
|
||||
find -E ./my_takeout_folder -regex ".*\([0-9]+\).*" -type f -delete
|
||||
|
||||
# Unix (Thanks to Stravos F. for pointing that out ❤️)
|
||||
find ./my_takeout_folder -regextype posix-extended -regex ".*\([0-9]+\).*" -type f -delete
|
||||
```
|
||||
|
||||
All the edited photos by google
|
||||
|
||||
```
|
||||
find ./my_takeout_folder -name "*edited*" -type f -delete
|
||||
```
|
||||
|
||||
And lastly remove all the empty folders.
|
||||
|
||||
```
|
||||
find ./my_takeout_folder -type d -empty -delete
|
||||
```
|
||||
|
||||
You probably will have multiple folders because you will have to download multiple archives. Simply unpack them all into one folder and run the scripts on to that folder.
|
||||
|
||||
If you are to lazy to run them manually just get this script I wrote
|
||||
|
||||
<script src="https://gist.github.com/CupCakeArmy/51070b311e6fd0a3f2d793bee3350ede.js"></script>
|
||||
|
||||
Then...
|
||||
|
||||
```
|
||||
chmod +x ./clean
|
||||
./clean my_folder_with_all_the_google_takouts
|
||||
```
|
||||
|
||||
Finally just drag and drop into the Photos app.
|
84
src/content/blog/going-beyond-npm-meet-yarn-pnpm.md
Normal file
84
src/content/blog/going-beyond-npm-meet-yarn-pnpm.md
Normal file
@ -0,0 +1,84 @@
|
||||
---
|
||||
title: 'Going beyond NPM: meet Yarn & pnpm'
|
||||
date: '2019-08-27'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'javascript'
|
||||
- 'node'
|
||||
- 'npm'
|
||||
- 'pnpm'
|
||||
- 'yarn'
|
||||
coverImage: './images/ruchindra-gunasekara-GK8x_XCcDZg-unsplash.jpg'
|
||||
---
|
||||
|
||||
If you are a JS developer you probably use NPM multiple times a day without thinking about it. It's the default package manager which ships with node.
|
||||
|
||||
But have you wondered what if there was another way of managing your (probably too many 😉) packages? We will look at [yarn](https://yarnpkg.com/en/) and [pnpm](https://pnpm.js.org/) as worthy rivals.
|
||||
|
||||
**Update** _27 Aug 2019 @ 21:23_
|
||||
As [this user](https://www.reddit.com/r/javascript/comments/cw64xt/going_beyond_npm_meet_yarn_pnpm/ey92a0i?utm_source=share&utm_medium=web2x) on reddit pointed out npm now supports offline installs too, so that part is the same for all three package managers. Also apparently the checksums, but I could now verify it.
|
||||
|
||||
**Update** _27 Aug 2019 @ 22:51_
|
||||
If you are having troubles with pnpm try using `pnpm i shamefully-flatten`. Thanks to [this reddit user](https://www.reddit.com/r/node/comments/cw64qq/going_beyond_npm_meet_yarn_pnpm/ey9aa1v?utm_source=share&utm_medium=web2x).
|
||||
|
||||
For the lazy readers: [Jump to the conclusion here](#conclusion).
|
||||
|
||||
You might wonder now: why? _Why should I bother reading this when NPM works perfectly_? Is this just another run to the latest framework? Don't worry: there are actual reasons you might want to switch.
|
||||
|
||||
#### Speed!!... or the lack of it?
|
||||
|
||||
The biggest issue that plagues npm is speed. Unfortunately even with the latest version (6) npm is slow. If you ever had to delete the node_modules folder to do a clean install on a bigger project you will know what I mean. Fans start to spin, laptop gets warm and you can go read an article while npm chews on the dependencies.
|
||||
|
||||
## Yarn to the rescue
|
||||
|
||||
Yarn came along in the early days and you definitely have heard about it wandering across Github. Back in the days (before npm 5 with the `package-lock.json`) Yarn addressed the issues with consistency by being the first to generate a lockfile (`yarn.lock`). This file could be checked in and the devs would have a consistent dependencies across multiple machines.
|
||||
|
||||
#### Speed
|
||||
|
||||
Yarn is often twice as fast as npm. It's truly impressive and you need to see it for yourself to believe it. The CLI output is also way more human-friendly.
|
||||
|
||||
#### Offline
|
||||
|
||||
Every package version is only downloaded once, so if you happen to loose connection or need to download the same package again you will gain a substantial speed boost since they are cached locally.
|
||||
_See update at the top_.
|
||||
|
||||
#### yarn upgrade-interactive
|
||||
|
||||
This is incredible 😍. If you run `yarn upgrade-interactive` you get an interactive CLI where you can choose what packages to upgrade and which not. It's a simple thing, but one you cannot live without anymore if tried it once.
|
||||
|
||||
#### yarn why
|
||||
|
||||
Similar to the previous command this is a very handy cli goodie. simply run `yarn why some-package` and yarn will tell you why it was installed, from which dependency it came from, etc.
|
||||
|
||||
#### Lack of npx
|
||||
|
||||
Unfortunately Yarn lacks the `npx` equivalent of npm, which is the only drawback I encountered while using yarn. Other than that yarn is a very fast and a solid alternative to npm.
|
||||
|
||||
## PNPM: The underdog
|
||||
|
||||
I truly love this project so I might be biased. They basically implemented a thought I had a while back: **reuse the same packages across your computer**. Confused? Let me explain:
|
||||
|
||||
Have you ever measured the size of the your node_modules?
|
||||
|
||||
```
|
||||
du -sh node_modules
|
||||
# --> 816M node_modules
|
||||
```
|
||||
|
||||
What?! 0.8Gb for a react-native project?!
|
||||
Unfortunately that is a pretty common reality and **pnpm** aims to solve that.
|
||||
|
||||
PNPM links your packages with symlinks. This means that **the same version of a package only exists once** on your computer. If you ever install the same package twice, it will simply symlinked to your node_modules. 🚀
|
||||
[On top of that it's even faster than yarn.](https://github.com/pnpm/benchmarks-of-javascript-package-managers)
|
||||
|
||||
#### So perfection is achieved? Let's all switch to pnpm?
|
||||
|
||||
Unfortunately it's not that easy. If you start a new project you can probably go with pnpm, but with existing projects I had some problems with building my apps. So it's definitely experimental at best and should not be used without rigorous testing as it might break your app. pnpm also supports npx with `pnpx`.
|
||||
|
||||
## Conclusion Time
|
||||
|
||||
<table><tbody><tr><td></td><td><strong>Speed</strong></td><td><strong>NPX</strong></td><td><strong>Offline</strong></td><td><strong>Well supported</strong></td></tr><tr><td>npm</td><td>🐌</td><td>✅</td><td>✅</td><td>✅</td></tr><tr><td>yarn</td><td>🚄</td><td>❌</td><td>✅</td><td>✅</td></tr><tr><td>pnpm</td><td>🚀</td><td>✅</td><td>✅</td><td>❌</td></tr></tbody></table>
|
||||
|
||||
As you can see above there is no clear winner. NPM is the most compatible of course but really falls behind in terms of speed. **Yarn in my opinion is currently your best bet** and fallback to `npx your-command` when npx is needed.
|
||||
pnpm is an incredibly cool tool but is not ready yet for production. With react-native I can cause problems, but with the "normal" stacks it works very good. I will use pnpm for my personal projects from now on.
|
@ -0,0 +1,171 @@
|
||||
---
|
||||
title: 'How to avoid killing your MacBook / Laptop battery'
|
||||
date: '2019-07-23'
|
||||
categories:
|
||||
- 'general'
|
||||
tags:
|
||||
- 'battery'
|
||||
- 'laptop'
|
||||
- 'macbook'
|
||||
coverImage: './images/israel-palacio-ImcUkZ72oUs-unsplash-scaled.jpg'
|
||||
---
|
||||
|
||||
As of May of 2020 this is no more relevant! macOS 10.15.5 finally addressed this issue by not charging the battery to 100% depending on battery temperature, health and so on 🚀🚀🚀
|
||||
|
||||
There are a lot if misleading wisdom out there about batteries (e.g. it is ok to leave your laptop plugged in). The reasons behind it are pretty interesting and not at all trivial. If you want to know why: keep reading.
|
||||
|
||||
**TLDR;** [Jump to the solution](#solution)
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/israel-palacio-ImcUkZ72oUs-unsplash-scaled.jpg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Photo by [israel palacio](https://unsplash.com/@othentikisra?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/search/photos/electricity?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
> The worst situation is keeping a fully charged battery at elevated temperatures.
|
||||
>
|
||||
> batteryuniversity.com
|
||||
|
||||
Batteries are consumable items. This means the degrade over time and loose their ability to store energy. We will see how and why this happens and how to combat it.
|
||||
|
||||
## Why do batteries degrade?
|
||||
|
||||
In theoretical chemistry your battery should be able to last forever. But in the real world of course that's not possible. There are 2 main killers for batteries:
|
||||
|
||||
1. Heat
|
||||
2. Cycles (Especially above ~80%)
|
||||
|
||||
### Killer #1: Heat
|
||||
|
||||
With heat the internal materials of the battery start to loose their chemical form and therefore their capacity. Below is a table that illustrates how batteries react to temperature exposure.
|
||||
|
||||
_Estimated recoverable capacity when storing Li-ion for one year at various temperatures._
|
||||
|
||||
<table><tbody><tr><td><strong>Temperature</strong></td><td><strong>40% charge</strong></td><td><strong>100% charge</strong></td></tr><tr><td>0°C</td><td>98% (after 1 year)</td><td>94% (after 1 year)</td></tr><tr><td>25°C</td><td>96% (after 1 year)</td><td>80% (after 1 year)</td></tr><tr><td>40°C</td><td>85% (after 1 year)</td><td>65% (after 1 year)</td></tr><tr><td>60°C</td><td>75% (after 1 year)</td><td>60%<br>(after 3 months)</td></tr></tbody></table>
|
||||
|
||||
As we can see, a heated battery basically is a lost cause, especially when fully charged.
|
||||
|
||||
Unfortunately **there is little we can do for the heat issue** since the thermal design of our Laptop is fixed and the manufacturer will choose where to place the battery and if and how to cool it.
|
||||
|
||||
### Killer #2: Cycles
|
||||
|
||||
Over this one we have much more control. A cycle varies in definition, but basically it is a discharge, followed by a charge.
|
||||
|
||||
Why are cycles bad? Well whenever we move electrons around our battery either by using it or while charging, the chemical material is subjected to wear. Why exactly this happens is beyond my understanding of chemistry, so I won't try to explain it since I would probably do it wrong.
|
||||
|
||||
What I can tell you is how to charge and use your battery in the correct manner:
|
||||
|
||||
1. Ideally leave your battery between 30% and 80%
|
||||
2. Don't charge over 80% if not strictly required for a long trip or so.
|
||||
|
||||
### Don't believe me, trust the data
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/DST-cycles-web2.jpg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Capacity loss as a function of charge and discharge bandwidth. [Source](https://batteryuniversity.com/index.php/learn/article/how_to_prolong_lithium_based_batteries)
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
As we can observe above, all the tested bandwidth that regularly charged to a full 100% degraded the fastest. Don't do that.
|
||||
|
||||
Part of the problem is that as you can see in the graphic below is that while **the first 80% of the capacity is charged easily and quick, the last 80% to 100% have an exponential curve**. Those last percentages are really taxing on the battery because you are trying to stuff the last electrons inside an almost full battery. Imagine you stuffing a last bag into an almost full garbage. You will need to push it.
|
||||
|
||||
Making matters worse, the battery will heat up during the last steps of charging because of the strain that is undergoing. This only adds to the problem, since as we learned above heat is incredibly bad for capacities.
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/Battery-Charge-Voltage-vs-Time.png)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Charging graph for Lithium Batteries.
|
||||
Graph by [batteryuniversity.com](https://batteryuniversity.com/index.php/learn/article/charging_lithium_ion_batteries) - Colorised by [Android Authority](https://www.androidauthority.com/maximize-battery-life-882395/)
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
This is also the reason because electric vehicles only charge up to 80%. It increases the lifespan of the battery significantly. On the other hand, consumer **products like laptops and phones are more about selling you maximum battery life.** What they don't tell you is **how quick that advertised battery life is going to last after 6 months** of usage.
|
||||
|
||||
### Myth: It's ok to keep you laptop plugged in
|
||||
|
||||
This is a misconception that arose in recent years. While it is not completely wrong, it overlooks some important aspects.
|
||||
|
||||
What is true? Modern laptops and phones don't overcharge the battery and will switch to using only the powered cable as the source. **However** at some point the battery will dip below 97% and the laptop will start charge it again to 100%. Assuming you use your laptop for work the whole day, this will happen multiple times daily. **It will break it**.
|
||||
|
||||
## Solution
|
||||
|
||||
### For MacBooks (magsafe):
|
||||
|
||||
Put a piece of paper/cloth on your middle connector of your MacBook Magsafe charger **whenever your laptop plugged in for long periods**.
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/howto.jpg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
How-To protect the battery with Magsafe chargers
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
**Update:** I tried new methods, the one that seems the most practical is to use a little piece of tape that you can bend in front of the connector when needed.
|
||||
|
||||
##### Updated method with tape
|
||||
|
||||
Put a piece of tape on your middle connector. For simplicity you can just cover 3 pins and leave the 2 outside pins (does not matter which side) free.
|
||||
Now you can easily switch between loading the battery or just working on power **whenever your laptop plugged in for long periods**.
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/howto-1.jpg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Same method, just with tape. Much easier to use.
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
<figure>
|
||||
|
||||
![](images/status.jpg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Status of battery after modification
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
This will prevent your laptop from using **and** charging your battery while using it for a whole day.
|
||||
**Credits for the hack**: [https://superuser.com/a/1130375](https://superuser.com/a/1130375)
|
||||
|
||||
### Laptops with removable batteries:
|
||||
|
||||
Simply remove the battery when using laptop for long periods. This will prevent the heat of the laptop being transferred to the battery and it won't charge over and over again to 100%.
|
||||
|
||||
#### Sources
|
||||
|
||||
- [https://batteryuniversity.com/index.php/learn/article/charging_lithium_ion_batteries](https://batteryuniversity.com/index.php/learn/article/charging_lithium_ion_batteries)
|
||||
- [https://batteryuniversity.com/learn/article/bu_808b_what_causes_li_ion_to_die](https://batteryuniversity.com/learn/article/bu_808b_what_causes_li_ion_to_die)
|
||||
- [https://batteryuniversity.com/index.php/learn/article/how_to_prolong_lithium_based_batteries](https://batteryuniversity.com/index.php/learn/article/how_to_prolong_lithium_based_batteries)
|
||||
- [https://batteryuniversity.com/index.php/learn/article/do_and_dont_battery_table](https://batteryuniversity.com/index.php/learn/article/do_and_dont_battery_table)
|
||||
- [https://www.electricbike.com/how-to-make-lithium-battery-last/](https://www.electricbike.com/how-to-make-lithium-battery-last/)
|
||||
- [https://www.androidauthority.com/maximize-battery-life-882395/](https://www.androidauthority.com/maximize-battery-life-882395/)
|
343
src/content/blog/how-to-bring-your-neural-network-to-the-web.md
Normal file
343
src/content/blog/how-to-bring-your-neural-network-to-the-web.md
Normal file
@ -0,0 +1,343 @@
|
||||
---
|
||||
title: 'How to bring your neural network to the web'
|
||||
date: '2020-02-10'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'ai'
|
||||
- 'keras'
|
||||
- 'machine-learning'
|
||||
- 'tensorflow'
|
||||
coverImage: './images/natasha-connell-byp5TTxUbL0-unsplash-scaled-1.jpg'
|
||||
---
|
||||
|
||||
Artificial intelligence, neural networks, machine learning. I don't know which of them is the bigger buzzword. If we look past the hype there are some actually very interesting use cases for machine learning in the browser.
|
||||
|
||||
**For the lazy that simply what to just to the source code**
|
||||
[Here is the git repo](https://github.com/cupcakearmy/mnist) for you :)
|
||||
**Or simply go to the [finished website](https://mnist.nicco.io/)**
|
||||
|
||||
Today we will look on how to train a simple mnist digit recogniser and then export it into a website where we then can see it in action. Therefore this article will be split into three parts
|
||||
|
||||
1. Training
|
||||
2. Export & import the pre-trained model into a website
|
||||
3. Build a simple website where we can use the model.
|
||||
|
||||
Also I am not going to explain what machine learning is, as there are enough guides, videos, podcasts, ... that already do a much better job than I could and would be outside the scope of this article.
|
||||
|
||||
So the first thing we need to understand is that we will not train the model in the browser. That is a job for GPUs and the goal here is only to use a pre-trained model inside of the browser. Training is a much more resource intensive task than simply using the net.
|
||||
|
||||
## Training the model
|
||||
|
||||
So, the first step is to actually have a model. I will do this in tensorflow 2.0 using the now included keras api. This means Python 🎉
|
||||
|
||||
The code below is basically an adapted version of the [keras hello world example](https://keras.io/examples/mnist_cnn/).
|
||||
If you want to run the code yourself (which you should!) simply head over to [Google Colab](https://colab.research.google.com), create a new file and just paste the code. There you can run it for free on GPUs which is pretty dope!
|
||||
|
||||
```py
|
||||
from tensorflow.keras.datasets import mnist
|
||||
from tensorflow.keras.models import Sequential
|
||||
from tensorflow.keras.layers import Dense, Dropout, Flatten
|
||||
from tensorflow.keras.layers import Conv2D, MaxPooling2D
|
||||
from tensorflow.keras.utils import to_categorical
|
||||
|
||||
(x_train, y_train), (x_test, y_test) = mnist.load_data()
|
||||
|
||||
# Reshaping for channels_last (tensorflow) with one channel
|
||||
size = 28
|
||||
print(x_train.shape, x_test.shape)
|
||||
x_train = x_train.reshape(len(x_train), size, size, 1).astype('float32')
|
||||
x_test = x_test.reshape(len(x_test), size, size, 1).astype('float32')
|
||||
print(x_train.shape, x_test.shape)
|
||||
|
||||
# Normalize
|
||||
upper = max(x_train.max(), x_test.max())
|
||||
lower = min(x_train.min(), x_test.min())
|
||||
print(f'Max: {upper} Min: {lower}')
|
||||
x_train /= upper
|
||||
x_test /= upper
|
||||
|
||||
total_classes = 10
|
||||
y_train = to_categorical(y_train, total_classes)
|
||||
y_test = to_categorical(y_test, total_classes)
|
||||
|
||||
# Make the model
|
||||
model = Sequential()
|
||||
model.add(Conv2D(64, (3, 3), activation='relu', input_shape=(size,size, 1), data_format='channels_last'))
|
||||
model.add(Conv2D(32, (3, 3), activation='relu'))
|
||||
model.add(MaxPooling2D(pool_size=(2, 2)))
|
||||
model.add(Dropout(0.25))
|
||||
model.add(Flatten())
|
||||
model.add(Dense(128, activation='relu'))
|
||||
model.add(Dropout(0.5))
|
||||
model.add(Dense(total_classes, activation='softmax'))
|
||||
|
||||
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
|
||||
|
||||
# Train
|
||||
model.fit(x_train, y_train,
|
||||
batch_size=32,
|
||||
epochs=12,
|
||||
verbose=True)
|
||||
|
||||
score = model.evaluate(x_test, y_test, verbose=0)
|
||||
print('Test loss:', score[0])
|
||||
print('Test accuracy:', score[1])
|
||||
```
|
||||
|
||||
We can run this and we will get a pretty good accuracy. The MNIST dataset ist not very hard to train.
|
||||
|
||||
## Export the model
|
||||
|
||||
Now the conventional way to save a model is to use the `model.save("model.h5")` method provided by keras. This uses the h5 file format.
|
||||
Unfortunately this is not compatible with tensorflow-js. So we need another way.
|
||||
|
||||
There is a package called tensorflowjs for python (confusing right? 😅) that provides the functionality we need
|
||||
|
||||
```ts
|
||||
import tensorflowjs as tfjs
|
||||
|
||||
tfjs.converters.save_keras_model(model, './js')
|
||||
```
|
||||
|
||||
It save the model data inside the `./js` folder ready to be used.
|
||||
Inside there you will find a `model.json` that basically describes the structure of the model and something like `group1-shard1of1.bin` that contains the fitted weights.
|
||||
|
||||
## Import the model
|
||||
|
||||
Now we are ready to import that. First we need to install the `@tensorflow/tfjs` package.
|
||||
|
||||
```ts
|
||||
import * as tf from '@tensorflow/tfjs'
|
||||
|
||||
let model
|
||||
|
||||
tf.loadLayersModel('/model.json').then((m) => {
|
||||
model = m
|
||||
})
|
||||
```
|
||||
|
||||
Ok how do I use that now?
|
||||
|
||||
```ts
|
||||
const tensor = tf.tensor(new Uint8Array(ourData), [1, 28, 28, 1])
|
||||
const prediction = model.predict(tensor)
|
||||
```
|
||||
|
||||
**What is happening here?**
|
||||
In order to predict a value we first need a tensor (vector) the same shape as our original input with which we trained the model with. In our case that is 1x28x28x1.
|
||||
Also we will convert our pixel data into a `Uint8Array`.
|
||||
|
||||
## Using the canvas element to draw and predict numbers
|
||||
|
||||
I'm not gonna talk about what bundler, etc. I'm using. If you interested simply have a look at the [git repo](https://github.com/cupcakearmy/mnist).
|
||||
|
||||
First lets write some basic html for the skeleton of our page.
|
||||
|
||||
```html
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
body > div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div canvas {
|
||||
display: inline-block;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
div input {
|
||||
display: inline-block;
|
||||
margin-top: 0.5em;
|
||||
padding: 0.5em 2em;
|
||||
background: white;
|
||||
outline: none;
|
||||
border: 1px solid;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>MNIST (Pretrained)</h1>
|
||||
<canvas id="can" width="28" height="28"></canvas>
|
||||
<br />
|
||||
<input id="clear" type="button" value="clear" />
|
||||
<br />
|
||||
<input id="test" type="button" value="test" />
|
||||
<br />
|
||||
<h2 id="result"></h2>
|
||||
<a href="https://github.com/cupcakearmy/mnist">
|
||||
<h3>source code</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<script src="./tf.js"></script>
|
||||
<script src="./canvas.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Next we need come short code for drawing on a canvas.
|
||||
The code is adapted from [this stackoverflow answer](https://stackoverflow.com/a/8398189) and reduced to the only the basics we need.
|
||||
|
||||
In essence it's a canvas that listens on our mouse events and fills the pixels with black. Nothing more.
|
||||
|
||||
```ts
|
||||
/* jslint esversion: 6, asi: true */
|
||||
|
||||
var canvas,
|
||||
ctx,
|
||||
flag = false,
|
||||
prevX = 0,
|
||||
currX = 0,
|
||||
prevY = 0,
|
||||
currY = 0,
|
||||
dot_flag = false
|
||||
|
||||
var x = 'black',
|
||||
y = 2
|
||||
|
||||
function init() {
|
||||
canvas = document.getElementById('can')
|
||||
ctx = canvas.getContext('2d')
|
||||
w = canvas.width
|
||||
h = canvas.height
|
||||
|
||||
canvas.addEventListener(
|
||||
'mousemove',
|
||||
function (e) {
|
||||
findxy('move', e)
|
||||
},
|
||||
false
|
||||
)
|
||||
canvas.addEventListener(
|
||||
'mousedown',
|
||||
function (e) {
|
||||
findxy('down', e)
|
||||
},
|
||||
false
|
||||
)
|
||||
canvas.addEventListener(
|
||||
'mouseup',
|
||||
function (e) {
|
||||
findxy('up', e)
|
||||
},
|
||||
false
|
||||
)
|
||||
canvas.addEventListener(
|
||||
'mouseout',
|
||||
function (e) {
|
||||
findxy('out', e)
|
||||
},
|
||||
false
|
||||
)
|
||||
|
||||
window.document.getElementById('clear').addEventListener('click', erase)
|
||||
}
|
||||
|
||||
function draw() {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(prevX, prevY)
|
||||
ctx.lineTo(currX, currY)
|
||||
ctx.strokeStyle = x
|
||||
ctx.lineWidth = y
|
||||
ctx.stroke()
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function erase() {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
function findxy(res, e) {
|
||||
if (res == 'down') {
|
||||
prevX = currX
|
||||
prevY = currY
|
||||
currX = e.clientX - canvas.offsetLeft
|
||||
currY = e.clientY - canvas.offsetTop
|
||||
|
||||
flag = true
|
||||
dot_flag = true
|
||||
if (dot_flag) {
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = x
|
||||
ctx.fillRect(currX, currY, 2, 2)
|
||||
ctx.closePath()
|
||||
dot_flag = false
|
||||
}
|
||||
}
|
||||
if (res == 'up' || res == 'out') {
|
||||
flag = false
|
||||
}
|
||||
if (res == 'move') {
|
||||
if (flag) {
|
||||
prevX = currX
|
||||
prevY = currY
|
||||
currX = e.clientX - canvas.offsetLeft
|
||||
currY = e.clientY - canvas.offsetTop
|
||||
draw()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
```
|
||||
|
||||
And not the glue to put this together is the piece of code that listens on the "test" button.
|
||||
|
||||
```ts
|
||||
import * as tf from '@tensorflow/tfjs'
|
||||
|
||||
let model
|
||||
|
||||
tf.loadLayersModel('/model.json').then((m) => {
|
||||
model = m
|
||||
})
|
||||
|
||||
window.document.getElementById('test').addEventListener('click', async () => {
|
||||
const canvas = window.document.querySelector('canvas')
|
||||
|
||||
const { data, width, height } = canvas.getContext('2d').getImageData(0, 0, 28, 28)
|
||||
|
||||
const tensor = tf.tensor(new Uint8Array(data.filter((_, i) => i % 4 === 3)), [1, 28, 28, 1])
|
||||
const prediction = model.predict(tensor)
|
||||
const result = await prediction.data()
|
||||
const guessed = result.indexOf(1)
|
||||
console.log(guessed)
|
||||
window.document.querySelector('#result').innerText = guessed
|
||||
})
|
||||
```
|
||||
|
||||
Here we need to explain a few things.
|
||||
`canvas.getContext('2d').getImageData(0, 0, 28, 28)` simply returns a flattened array of the pixels from the point (0,0) to (28,28).
|
||||
|
||||
Then, instead of simply passing the data to the tensor. we need to do some magic with `data.filter` in order to get only every 3rd pixel. This is because our canvas has 3 channels + 1 alpha, but we only need to know if the pixel is black or not. We do this by simply filtering for the index mod 4
|
||||
|
||||
```ts
|
||||
data.filter((_, i) => i % 4 === 3)
|
||||
```
|
||||
|
||||
Lastly we need to interpret the result. `prediction.data()` return an array with 10 items. Because we have trained it that way that we only have 10 possible outcomes. 10 Digits right?
|
||||
Well in that case we simply search in which position in the array we have a 1 and the index is out solution.
|
||||
We search for a 1 because we only have floats from 0 to 1. So 1 is the maximum.
|
||||
|
||||
I hope this helped you understand the process better. It was pretty confusing at first for me too 😬
|
211
src/content/blog/how-to-search-in-the-jam.md
Normal file
211
src/content/blog/how-to-search-in-the-jam.md
Normal file
@ -0,0 +1,211 @@
|
||||
---
|
||||
title: 'How to search in the JAM'
|
||||
date: '2020-12-06'
|
||||
coverImage: './images/uriel-soberanes-gCeH4z9m7bg-unsplash.jpg'
|
||||
categories:
|
||||
- 'coding'
|
||||
tags:
|
||||
- 'jam-stack'
|
||||
- 'lunr'
|
||||
- 'search'
|
||||
- 'svelte'
|
||||
---
|
||||
|
||||
So a lot (me included) now are building JAM stack landing pages, shops, full-stack apps, etc. and while you can have a backend of course not all of them have. For those who don't: **How do we search?**
|
||||
|
||||
So there is the obvious [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) but that looks bad and it not really customizable. The results are very good, it's google after all. However for those who want something more custom: Here's one way how.
|
||||
|
||||
A working example can found right here [nicco.io/search](https://nicco.io/search) 😉
|
||||
We will look at the following:
|
||||
|
||||
1. How to implement the search
|
||||
2. Search Accuracy & Precision
|
||||
3. Performance & Size
|
||||
|
||||
We can't rely on a backend as discussed above, so the magic will happen at build time, like everything in the JAM-verse.
|
||||
|
||||
I've decided to go with the free and open source [lunr.js](https://lunrjs.com/) which is a simple but still quite powerful search engine that can run in the client.
|
||||
|
||||
```
|
||||
const idx = lunr(function () {
|
||||
this.ref('name')
|
||||
this.field('text')
|
||||
this.add({ name: 'A', text: 'Lorem...' })
|
||||
})
|
||||
const results = idx.search('Lor*')
|
||||
```
|
||||
|
||||
The first question that probably will pop out in your head is: "How will lunr be able to know what is on our website?" Here is where our work begins.
|
||||
|
||||
## Roadmap
|
||||
|
||||
1. Aggregate all the data of your site
|
||||
2. Prebuild the index and make it available as static JSON
|
||||
3. Load `lunr.js` into your site and start searching
|
||||
|
||||
## Preparing the Index
|
||||
|
||||
So I'm using [Sapper](https://sapper.svelte.dev/) for this blog so the examples will be based on it, but the same principle applies to all JAM tech.
|
||||
|
||||
First we need to aggregate all our data. In my case this means all the single pages, blog entries, projects and works. So I created a `/src/routes/search.json.js` file and got to work.
|
||||
|
||||
```ts
|
||||
import lunr from 'lunr'
|
||||
|
||||
import { getAll } from '../lib/wp'
|
||||
|
||||
function removeHTML(s) {
|
||||
return s.replace(/<.*?>|\s+|&#\d+;/g, ' ').trim()
|
||||
}
|
||||
|
||||
async function convertForIdx(type, fields = []) {
|
||||
// Load the data from Wordpress
|
||||
const items = await getAll(type)
|
||||
// Map only the fields we need and are relevant
|
||||
const defaults = ['title', 'content', 'slug']
|
||||
return items.map((item) => ({
|
||||
url: `${item.type}/${item.slug}`,
|
||||
data: [...defaults, ...fields].map((field) => removeHTML(item[field])).join(' '),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function get(req, res) {
|
||||
const all = await Promise.all([
|
||||
convertForIdx('projects', ['description']),
|
||||
convertForIdx('pages'),
|
||||
convertForIdx('posts'),
|
||||
convertForIdx('works', ['role']),
|
||||
])
|
||||
|
||||
const idx = lunr(function () {
|
||||
this.ref('url')
|
||||
this.field('data')
|
||||
|
||||
all.flat().forEach((doc) => this.add(doc))
|
||||
})
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify(idx))
|
||||
}
|
||||
```
|
||||
|
||||
First I get all the data from the Wordpress backend and for each item I select at least the `title` and `content` as I want them to be searchable. Then we remove any html tags with a dirty regexp and finally we build the index.
|
||||
|
||||
When we call `JSON.stringify(idx)` the precomputed index will be serialized to JSON. Otherwise every client would had to compute that on their CPU, wasting cycles and possibly battery. We don't want that.
|
||||
|
||||
Now I have the "search model" ready. You can have a look: [nicco.io/search.json](https://nicco.io/search.json)
|
||||
|
||||
## Integrating the search
|
||||
|
||||
It's time to integrate the search into the actual website 🚀
|
||||
|
||||
```html
|
||||
<script context="module">
|
||||
export async function preload() {
|
||||
const prebuilt = await this.fetch(`/search.json`).then((res) => res.json())
|
||||
return { prebuilt }
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import lunr from 'lunr'
|
||||
|
||||
import SearchResult from '../components/SearchResult.svelte'
|
||||
|
||||
export let prebuilt
|
||||
|
||||
let needle
|
||||
let results = []
|
||||
|
||||
async function search(needle) {
|
||||
if (!needle || !idx) {
|
||||
results = []
|
||||
} else {
|
||||
let found = idx.search(needle + '~1')
|
||||
if (!found.length) found = idx.search(needle + '*')
|
||||
results = found.slice(0, 20)
|
||||
}
|
||||
}
|
||||
|
||||
$: idx = lunr.Index.load(prebuilt)
|
||||
$: search(needle)
|
||||
</script>
|
||||
|
||||
<input bind:value="{needle}" placeholder="needle" />
|
||||
<ul>
|
||||
{#each results as result (result.ref)}
|
||||
<SearchResult {result} />
|
||||
{/each}
|
||||
</ul>
|
||||
```
|
||||
|
||||
The first thing we do is load our preloaded `/search.json` and loading into an instance of `lunr`. This only need to happen once, once the index is loaded we ready to go.
|
||||
|
||||
```ts
|
||||
const idx = lunr.Index.load(prebuilt)
|
||||
```
|
||||
|
||||
For the searching itself `lunr` has quite a [few options](https://lunrjs.com/guides/searching.html). The most relevant for me where the wildcard and fuzzy search. While wildcard is good for when we don't have completed a word yet, fuzzy helps us with typos.
|
||||
|
||||
```ts
|
||||
const fuzzy = idx.search(needle + '~1') // foo~1
|
||||
```
|
||||
|
||||
While not explicitly said in the docs I'm guessing they use the [Levenshtein Distance](https://en.wikipedia.org/wiki/Levenshtein_distance), which means `~1` will replace at most 1 char.
|
||||
|
||||
```ts
|
||||
const wildcard = idx.search(needle + '*') // fo*
|
||||
```
|
||||
|
||||
Wildcard are straight forward. `lunr` supports any kind: `*oo`, `f*o` and `fo*`.
|
||||
|
||||
The result is an array with the `ref` field so you can find the related item and a `score`. They are already sorted by score, so basically you just need to write a for loop.
|
||||
|
||||
## Search Quality
|
||||
|
||||
Now the accuracy and precision are of course on par with Google, but way good enough for a blog or a smaller site. However in 1h you can add search to your JAM site without much work and you stay google free.
|
||||
Also this approach gives you all the artistic liberties over the design.
|
||||
|
||||
## Performance & Size
|
||||
|
||||
Since we are prebuilding and packaging the whole site into one big `JSON` file it's worth taking a look at the size of the index.
|
||||
|
||||
For this I took the [Iliad by Homer](https://gutenberg.org/ebooks/6130) and slitted it up into different amount of pieces to simulate the amount of pages. At the same tame, the more pieces, the smaller the single content on one "page".
|
||||
|
||||
Please not that it's ~1mb of plain text so it's quite a lot.
|
||||
You can get the source code for the "test" [here](https://gist.github.com/cupcakearmy/242b54ee6b1a914896390c91846aa4d4).
|
||||
|
||||
### Variable size documents
|
||||
|
||||
<figure>
|
||||
|
||||
![](https://api.nicco.io/wp-content/uploads/2020/12/Lunr-Index-Size-Compresion.svg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Graph of Lunr Index size
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
As you can see, with `1000` each around `1.15k` in size we end up with a compressed size of `563 KiB` which starts to get big.
|
||||
|
||||
### A more real example
|
||||
|
||||
Here is an example where each document is around `10k` in size. Roughly double the text amount needed for this blog post. Then we add an ever increasing amount of documents to the index and watch it grow.
|
||||
|
||||
<figure>
|
||||
|
||||
![](https://api.nicco.io/wp-content/uploads/2020/12/Lunr.js-Index-Size-10k-Document-Size.svg)
|
||||
|
||||
<figcaption>
|
||||
|
||||
Chart of 10k sized documents building the index.
|
||||
|
||||
</figcaption>
|
||||
|
||||
</figure>
|
||||
|
||||
The results are very different of course. Please note the the second graph has a logarithmic scale! If we compare the compressed size at `500` documents we have `494kb @2.3k/doc` vs `1.09MiB @10k/doc`. Basically double, which is not that bad if we consider that the documents are around 5 times bigger.
|
||||
|
||||
Hope you enjoyed and learned something, take care ❤️
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:54ef9ac3fb65009c53d55f103eadb1f6c1b9ac9cb93019f161d5c0d842576e61
|
||||
size 191614
|
3
src/content/blog/images/DST-cycles-web2.jpg
Normal file
3
src/content/blog/images/DST-cycles-web2.jpg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ee60b8040150d154afd21faf3112421b9b43cacb0626a5b4c53c0590c69626a0
|
||||
size 151085
|
3
src/content/blog/images/IMG_0160.jpeg
Normal file
3
src/content/blog/images/IMG_0160.jpeg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:767f3552e0f0244b006c0bcb5a8d3c39b2fb83d1eb008cd5abb6d8c8012c11da
|
||||
size 182432
|
3
src/content/blog/images/IMG_1709.jpeg
Normal file
3
src/content/blog/images/IMG_1709.jpeg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f43dfc65d7f710d7cfe7f21d168c5142c450b95b363e23f340d31135676bedc4
|
||||
size 33825
|
3
src/content/blog/images/IMG_1710.jpeg
Normal file
3
src/content/blog/images/IMG_1710.jpeg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1e65961b86eb1febbe48eb9292c9a54eebdb61314a7b1223d7c9112423477dcf
|
||||
size 175459
|
3
src/content/blog/images/IMG_1710.png
Normal file
3
src/content/blog/images/IMG_1710.png
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e83975d7ec576ef6ca6c4fc7a007fd82d8066cdfc42ee097527f989ccf025756
|
||||
size 2027604
|
3
src/content/blog/images/IMG_1711.jpeg
Normal file
3
src/content/blog/images/IMG_1711.jpeg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f1dfeb6c5d59b355b38a50a0888b178e9fa9fa93bd0d26dd94ffd87ecbc0fae3
|
||||
size 136338
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a4a5b54dfff9307f3460189e6e80eedd6c80ee64eb3ae25bf5c7df3c3cccf882
|
||||
size 56274
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b0f241b1da4e817d415f8984ce54830276b0631042b31815a5569dd6937eb2ad
|
||||
size 258865
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2b5d821660053d05743eb6439d652e77a3d4699a45884a9bce88169835c55c8a
|
||||
size 564881
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5db61551895b9969afdb1356560f03c1918faca5e68169b6ac2d200ce700ce90
|
||||
size 73938
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88c62af32b444a6928c85e8f31cad2f4bef8ebed33893b0a28894d6f87593599
|
||||
size 42234
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eb058e043414fe65d83876644ed84a7e2e336ad8b1b4d771062db8494c036e79
|
||||
size 101970
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b70f8cacc7eaa9ec3127c96cf3d3fecddda61a35313b8fd6f42622d1f2983858
|
||||
size 50254
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:641d3e8a3529e6e026773d16bc105b71a0ae27d8361faada37d52551ef198a0f
|
||||
size 688323
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ef1c569cdcf9851539f06176d0c9e0debc52875976ff330b1cc6971572fe1404
|
||||
size 665199
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:641e531b6383c8552a05586e2ac44c53718c0b6130d72f23dcb8edf74ee4b3dd
|
||||
size 794403
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2f1402d168936c5ef8b97bc4f30afe3d4bae64136feb8d85b8e85bc15d3810d3
|
||||
size 287558
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:40588829e34a45f71777c36e1dfdc9078bdf88d3196051034d20249d3a0ea539
|
||||
size 104152
|
3
src/content/blog/images/auth-sequence-auth-code-pkce.png
Normal file
3
src/content/blog/images/auth-sequence-auth-code-pkce.png
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e7b72e16d3f5bcd368eb3b39078e32d0ef904023b7984f32fa2d0e364109479
|
||||
size 43477
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48603b5e68e25cce1593bf8578f5cc882c1612c213d0c3041312f018087a9a78
|
||||
size 191775
|
3
src/content/blog/images/cards-scaled.jpg
Normal file
3
src/content/blog/images/cards-scaled.jpg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ebc09fc3389fdd05f9d559692c3d24eb7bd99adba36cabfdcf707975670c2474
|
||||
size 483287
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7856b748cc26c928c9fd5d43af23d43f72a6f11bf277685ef4b63ad8ada0528e
|
||||
size 495251
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:366666945890876b458c89ddfb94d4bab8e02f686a08b1d3bc04f0ed0e03e1cc
|
||||
size 272668
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a3f3295dd8580c51c510f34b5c5252c56d77be75d1a93ce83583c6a4308e636f
|
||||
size 4596995
|
3
src/content/blog/images/data.gif
Normal file
3
src/content/blog/images/data.gif
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a326b48b5b9355bdef2a43fe5298559d45ad645f911519b9e398f981a1390e80
|
||||
size 318810
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:418f648ed44631401d9b4887c951b1db562cb02d981258f410768284dccd4945
|
||||
size 160637
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:394cb4b320efe0eaafc527569f2e04e57f25f6ca7ad70a6cc951af981012be2a
|
||||
size 587956
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7b41d27d824c56267c2cb666d906c1bede2fdaa1de74f9957e26d502c50cbc2b
|
||||
size 1115156
|
3
src/content/blog/images/howto-1.jpg
Normal file
3
src/content/blog/images/howto-1.jpg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:39057dd4352501b6a8ad5aa62f28662cc0b1310fbbf03c5ad3ee67d0ca3abb9a
|
||||
size 143009
|
3
src/content/blog/images/howto.jpg
Normal file
3
src/content/blog/images/howto.jpg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8aaebdf6ec81ea65d61c9db30a93e0e6da65c06b874a614393d260ef135c3ee9
|
||||
size 170032
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:728917127520026b290af0348ef70dee6d36dbd3ec3f72b76d3c2b1fca3bdfdb
|
||||
size 1205666
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:24f5c8a7e2fce080c7f9090f9db4f8ae5d2644f08eba8671da802bd76880e679
|
||||
size 523148
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a7c6f461682a6a5d127655c2d67b90e1a1293c3fbe9bca763db8ab855e1c534
|
||||
size 907098
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:82a2dfcd574fef01451a2e62c5efa1975445682c70588bca4e06f1421be4c24d
|
||||
size 225187
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:16f73c9118460e939f16070572407482c45a885a5f28163d3fe21a1ae63efc4f
|
||||
size 547290
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bdd3909cf6516e603c072d23f1ba8a9394dc29cfdaff787c0a6ecd3ce0083c99
|
||||
size 351535
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:012f5cffade95b8952c6da835c5ec9badacce4ab222b8cf6a59ce2d62fb2fd4a
|
||||
size 645501
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:500b324d99022e3410afa467743fff186a85700462e66487c11f5b7c3722e9d1
|
||||
size 244788
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:08eb7d31540d64f49de77452565c48015bc4da3e5753ba9ed93ad19e70eaef48
|
||||
size 190710
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4def0c84923defe516c9d1c7e4be767ff6a7ecfdccf55eb674abf3b5144dbc06
|
||||
size 92471
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7302bfcb09c4fcaa7cbc1b51254697333f3a0b46090809779bd4a7c49442454a
|
||||
size 445129
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0160fa88ffe90a0f9a1846e178b4554db761855aea838973bc32eb700cd28af9
|
||||
size 247279
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ff024e32cdb7fa6fe8624cc7c42f16a3b7c2981646ab0f01c6e81c6b56a02f3
|
||||
size 393856
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cd058a3a94865f064a2cc5300267657d449d7cb787662b2ceb945ebe80dfafc7
|
||||
size 555132
|
3
src/content/blog/images/permissions.gif
Normal file
3
src/content/blog/images/permissions.gif
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dc7bc17cdc583accf94e27eadb4bdb49c91e58c6add60074d24e6b71b1d35e19
|
||||
size 485939
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f1258ab1f0583e4cb7225f97c777685f0b35021e71ab9ce3ac838fbcaad714d
|
||||
size 299888
|
3
src/content/blog/images/register-bot.jpg
Normal file
3
src/content/blog/images/register-bot.jpg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4aa44481d72090565e8664bdfbb506f833cabc9a0e1ac988807912d86dc61c71
|
||||
size 97464
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a9a2b0d913d11a177032bf5c8148c43e64e97dcff8c0224645f82a8e5e7280c9
|
||||
size 4819801
|
3
src/content/blog/images/status.jpg
Normal file
3
src/content/blog/images/status.jpg
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dd832991f433a79e9794b29d80c170486d55187b565e1234f69e2cc141291042
|
||||
size 57217
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a2d827c72bad2602c6f1f9fa29fcb2471de06dd725feb338b247b034480da05
|
||||
size 773178
|
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2e195c9ed36de6e74ff814f663d80387de82245685784e9c5b52e2831d860f09
|
||||
size 615029
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user