revert resource
20
.eslintrc.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||
"plugins": ["svelte3", "@typescript-eslint"],
|
||||
"ignorePatterns": ["*.cjs"],
|
||||
"overrides": [{ "files": ["*.svelte"], "processor": "svelte3/svelte3" }],
|
||||
"settings": {
|
||||
"svelte3/typescript": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 2019
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2017": true,
|
||||
"node": true
|
||||
}
|
||||
}
|
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
35
.github/CONTRIBUTING.md
vendored
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for ur interest in contributing to Urara! Please take a moment to read this document before submitting a pull request.
|
||||
|
||||
## Pull requests
|
||||
|
||||
pls ask before u start working on any important new feature.
|
||||
|
||||
for minor features and bug fixes: I will accept them as long as I think the code quality is good enough.
|
||||
|
||||
### Commit message
|
||||
|
||||
This is not mandatory at this time, but pls use [gitmoji](https://gitmoji.dev) and [Conventional Commits](https://www.conventionalcommits.org) whenever possible.
|
||||
|
||||
### Check the code
|
||||
|
||||
Run this command to check the code:
|
||||
|
||||
```bash
|
||||
pnpm check
|
||||
```
|
||||
|
||||
In general, expect to see output like this:
|
||||
|
||||
```text
|
||||
svelte-check found 0 errors, 0 warnings, and 0 hints
|
||||
```
|
||||
|
||||
### Format the code
|
||||
|
||||
run this command to format the code:
|
||||
|
||||
```bash
|
||||
pnpm format
|
||||
```
|
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
custom: ['https://giveth.io/project/urara', 'https://donate.lol/eth/0xaBdB3f715198A4d7e6591b6ebBE8Ccf235e5D752']
|
14
.gitignore
vendored
Normal file
|
@ -0,0 +1,14 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
.svelte-kit
|
||||
/package
|
||||
src/routes/**/
|
||||
static/
|
||||
build
|
||||
.vercel_build_output/
|
||||
.netlify/
|
||||
.env.local
|
||||
.env.**.local
|
||||
myblog/urara/2022-06-12-appwrite.md
|
||||
*.config.js
|
||||
urara.js
|
1
.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
v18.4.0
|
7
.prettierignore
Normal file
|
@ -0,0 +1,7 @@
|
|||
.svelte-kit/**
|
||||
static/**
|
||||
build/**
|
||||
node_modules/**
|
||||
pnpm-lock.yaml
|
||||
.netlify/**
|
||||
.vercel_build_output/**
|
13
.prettierrc.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"printWidth": 128,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"endOfLine": "lf",
|
||||
"arrowParens": "avoid",
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": true,
|
||||
"htmlWhitespaceSensitivity": "ignore"
|
||||
}
|
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["svelte.svelte-vscode", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||
}
|
10
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.eol": "\n",
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"svelte.plugin.css.diagnostics.enable": false,
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
30
README.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
My Tech Blog, base on [Urara](https://github.com/importantimport/urara)
|
||||
|
||||
## Fork from
|
||||
|
||||
- [kwchang083.dev](https://github.com/kwchang0831/kwchang0831.dev)
|
||||
- [kwaa/blog](https://github.com/kwaa/blog)
|
||||
|
||||
### Custom Featrues:
|
||||
|
||||
- Copy code to clipboard
|
||||
- Project page
|
||||
- [zhlint](https://github.com/Jinjiang/zhlint)
|
||||
- Quote Style
|
||||

|
||||
|
||||
## Deploy
|
||||
|
||||
- [Netlify]
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Note Page
|
||||
- [ ] Archie Page
|
||||
- [ ] Refactoring the atom feed format
|
||||
- [ ] NeoDB component
|
||||
- [ ] ...
|
||||
|
||||
### License:
|
||||
|
||||
[CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
|
7
deploy.sh
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
echo "1. build locally"
|
||||
netlify build
|
||||
echo "2.deploy test"
|
||||
netlify deploy
|
||||
echo "3.deploy"
|
||||
netlify deploy --prod
|
110
mdsvex.config.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
// mdsvex config type
|
||||
import type { MdsvexOptions } from 'mdsvex'
|
||||
|
||||
// rehype plugins
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings'
|
||||
import rehypeExternalLinks from 'rehype-external-links'
|
||||
|
||||
// urara remark plugins
|
||||
import type { Node, Data } from 'unist'
|
||||
import { statSync } from 'fs'
|
||||
import { parse, join } from 'path'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import Slugger from 'github-slugger'
|
||||
import remarkFootnotes from 'remark-footnotes'
|
||||
|
||||
// highlighter
|
||||
import { escapeSvelte } from 'mdsvex'
|
||||
import { lex, parse as parseFence } from 'fenceparser'
|
||||
import { renderCodeToHTML, runTwoSlash, createShikiHighlighter } from 'shiki-twoslash'
|
||||
type VALUE = { [key in string | number]: VALUE } | Array<VALUE> | string | boolean | number
|
||||
|
||||
const remarkUraraFm =
|
||||
() =>
|
||||
(tree: Node<Data>, { data, filename }: { data: { fm?: Record<string, unknown> }; filename?: string }) => {
|
||||
const filepath = (filename as string).split('/src/routes')[1]
|
||||
const { dir, name } = parse(filepath)
|
||||
if (!data.fm) data.fm = {}
|
||||
// Generate slug & path
|
||||
data.fm.slug = filepath
|
||||
data.fm.path = join(dir, `/${name}`.replace('/index', '').replace('.svelte', ''))
|
||||
// Generate ToC
|
||||
if (data.fm.toc !== false) {
|
||||
const [slugs, toc]: [slugs: Slugger, toc: { depth: number; title: string; slug: string }[]] = [new Slugger(), []]
|
||||
visit(tree, 'heading', (node: { depth: number }) => {
|
||||
toc.push({
|
||||
depth: node.depth,
|
||||
title: toString(node),
|
||||
slug: slugs.slug(toString(node), false)
|
||||
})
|
||||
})
|
||||
data.fm.toc = toc
|
||||
}
|
||||
// Auto-read created & updated
|
||||
if (!data.fm.created || !data.fm.updated) {
|
||||
const { ctime, mtime } = statSync(new URL(`./urara${filepath}`, import.meta.url))
|
||||
if (!data.fm.created) data.fm.created = ctime
|
||||
if (!data.fm.updated) data.fm.updated = mtime
|
||||
}
|
||||
}
|
||||
|
||||
// Better type definitions needed
|
||||
const remarkUraraSpoiler = () => (tree: Node<Data>) =>
|
||||
visit(tree, 'paragraph', (node: any) => {
|
||||
const { children } = node
|
||||
const text = children[0].value
|
||||
const re = /\|\|(.{1,}?)\|\|/g
|
||||
if (re.test(children[0].value)) {
|
||||
children[0].type = 'html'
|
||||
children[0].value = text.replace(re, (_match: unknown, p1: string) => `<span class="spoiler">${p1}</span>`)
|
||||
}
|
||||
return node
|
||||
})
|
||||
|
||||
const defineConfig = (config: MdsvexOptions) => config
|
||||
|
||||
export default defineConfig({
|
||||
extensions: ['.svelte.md', '.md'],
|
||||
smartypants: {
|
||||
dashes: 'oldschool'
|
||||
},
|
||||
layout: {
|
||||
_: './src/lib/components/post_layout.svelte'
|
||||
},
|
||||
highlight: {
|
||||
highlighter: async (code, lang, meta) => {
|
||||
let fence: Record<string, VALUE> | null
|
||||
let twoslash: any
|
||||
try {
|
||||
fence = parseFence(lex([lang, meta].filter(Boolean).join(' ')))
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse the codefence for this code sample \n${code}`)
|
||||
}
|
||||
if (fence?.twoslash === true) twoslash = runTwoSlash(code, lang as string)
|
||||
return `{@html \`${escapeSvelte(
|
||||
renderCodeToHTML(
|
||||
code,
|
||||
lang as string,
|
||||
fence ?? {},
|
||||
{ themeName: 'material-default' },
|
||||
await createShikiHighlighter({ theme: 'material-default' }),
|
||||
twoslash
|
||||
)
|
||||
)}\` }`
|
||||
}
|
||||
},
|
||||
remarkPlugins: [remarkUraraFm, remarkUraraSpoiler, [remarkFootnotes, { inlineNotes: true }]],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
[rehypeAutolinkHeadings, { behavior: 'wrap' }],
|
||||
[
|
||||
rehypeExternalLinks,
|
||||
{
|
||||
rel: ['nofollow', 'noopener', 'noreferrer', 'external'],
|
||||
target: '_blank'
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
59
netlify.toml
Normal file
|
@ -0,0 +1,59 @@
|
|||
[build]
|
||||
command = "npx pnpm i --store=node_modules/.pnpm-store && npx pnpm build"
|
||||
publish = "build"
|
||||
|
||||
|
||||
[build.environment]
|
||||
NPM_FLAGS = "--version"
|
||||
AWS_LAMBDA_JS_RUNTIME = "nodejs16.x"
|
||||
|
||||
[functions]
|
||||
node_bundler = "esbuild"
|
||||
|
||||
[[headers]]
|
||||
for = "/manifest.webmanifest"
|
||||
[headers.values]
|
||||
Content-Type = "application/manifest+json"
|
||||
|
||||
[[headers]]
|
||||
for = "/assets/*"
|
||||
[headers.values]
|
||||
cache-control = '''
|
||||
max-age=31536000,
|
||||
immutable
|
||||
'''
|
||||
|
||||
[[headers]]
|
||||
for = "/*"
|
||||
[headers.values]
|
||||
Access-Control-Allow-Origin = "https://seviche.cc"
|
||||
X-Frame-Options = "DENY"
|
||||
X-Content-Type-Options = "nosniff"
|
||||
X-XSS-Protection = "1; mode=block"
|
||||
Content-Security-Policy = "style-src 'self' 'unsafe-inline' https://cdn.commento.io/css/commento.css http://fonts.cdnfonts.com/css/lato ; script-src 'self' 'unsafe-inline' https://*.seviche.cc https://giscus.app https://hexoverc.vercel.app/umami.js https://cdn.splitbee.io/sb.js https://cdn.commento.io/js/commento.js"
|
||||
Referrer-Policy = "strict-origin-when-cross-origin"
|
||||
Permissions-Policy = "usb=()"
|
||||
|
||||
[[redirects]]
|
||||
from = "/.well-known/host-meta"
|
||||
to = "https://fed.brid.gy/.well-known/host-meta"
|
||||
status = 302
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/.well-known/host-meta.xrd"
|
||||
to = "https://fed.brid.gy/.well-known/host-meta.xrd"
|
||||
status = 302
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/.well-known/host-meta.jrd"
|
||||
to = "https://fed.brid.gy/.well-known/host-meta.jrd"
|
||||
status = 302
|
||||
force = true
|
||||
|
||||
[[redirects]]
|
||||
from = "/.well-known/webfinger"
|
||||
to = "https://fed.brid.gy/.well-known/webfinger"
|
||||
status = 302
|
||||
force = true
|
86
package.json
Normal file
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"name": "urara",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"repository": "importantimport/urara",
|
||||
"homepage": "https://github.com/importantimport/urara",
|
||||
"bugs": "https://github.com/importantimport/urara/issues",
|
||||
"author": "藍+85CD",
|
||||
"scripts": {
|
||||
"clean": "node urara.js clean",
|
||||
"tsc": "tsc -p tsconfig.node.json",
|
||||
"tsc:watch": "tsc -w -p tsconfig.node.json",
|
||||
"urara:build": "node urara.js build",
|
||||
"urara:watch": "node urara.js watch",
|
||||
"kit:dev": "cross-env NODE_OPTIONS=--max_old_space_size=7680 MODE=development vite dev",
|
||||
"kit:build": "cross-env NODE_OPTIONS=--max_old_space_size=7680 vite build",
|
||||
"dev:parallel": "npm-run-all -p -r tsc:watch urara:watch \"kit:dev {@} \" --",
|
||||
"dev": "npm-run-all -s tsc \"dev:parallel {@} \" --",
|
||||
"build": "npm-run-all -s tsc urara:build kit:build clean",
|
||||
"preview": "vite preview",
|
||||
"dev:urara": "node urara.js watch",
|
||||
"dev:kit": "export NODE_OPTIONS=--max_old_space_size=8192 && MODE=development svelte-kit dev",
|
||||
"build:urara": "node urara.ts build",
|
||||
"build:kit": "export NODE_OPTIONS=--max_old_space_size=8192 && svelte-kit build",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
|
||||
"format": "prettier --write --plugin-search-dir=. .",
|
||||
"zhlint": "zhlint urara/*/*.md --fix && zhlint urara/*.md --fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/heroicons-outline": "^1.1.2",
|
||||
"@iconify-json/heroicons-solid": "^1.1.2",
|
||||
"@iconify-json/ic": "^1.1.9",
|
||||
"@iconify-json/simple-icons": "1.1.21",
|
||||
"@iconify-json/material-symbols": "1.1.14",
|
||||
"@iconify-json/mdi": "^1.1.30",
|
||||
"@iconify-json/uil": "^1.1.2",
|
||||
"@sveltejs/adapter-auto": "1.0.0-next.64",
|
||||
"@sveltejs/adapter-node": "1.0.0-next.86",
|
||||
"@sveltejs/adapter-static": "1.0.0-next.39",
|
||||
"@sveltejs/kit": "1.0.0-next.405",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@types/node": "^18.7.3",
|
||||
"@types/unist": "^2.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.0",
|
||||
"@typescript-eslint/parser": "^5.33.0",
|
||||
"autoprefixer": "^10.4.8",
|
||||
"chalk": "^5.0.1",
|
||||
"chokidar": "^3.5.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"cssnano": "^5.1.13",
|
||||
"daisyui": "^2.24.0",
|
||||
"eslint": "^8.21.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"fenceparser": "^2.2.0",
|
||||
"fff-flavored-frontmatter": "~0.2.1",
|
||||
"github-slugger": "^1.4.0",
|
||||
"mdast-util-to-string": "^3.1.0",
|
||||
"mdsvex": "^0.10.6",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.16",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-svelte": "^2.7.0",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-external-links": "^2.0.0",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"remark": "^14.0.2",
|
||||
"remark-footnotes": "~2.0.0",
|
||||
"shiki-twoslash": "^3.1.0",
|
||||
"svelte": "^3.49.0",
|
||||
"svelte-bricks": "^0.1.7",
|
||||
"svelte-check": "^2.8.0",
|
||||
"svelte-preprocess": "^4.10.7",
|
||||
"svelte-typeahead": "^4.2.4",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"tslib": "^2.4.0",
|
||||
"typescript": "^4.7.4",
|
||||
"unist-util-visit": "^4.1.0",
|
||||
"unocss": "^0.45.6",
|
||||
"vite": "^3.0.7",
|
||||
"vite-plugin-pwa": "^0.12.3",
|
||||
"workbox-window": "^6.5.4"
|
||||
}
|
||||
}
|
6320
pnpm-lock.yaml
Normal file
184
src/app.css
Normal file
|
@ -0,0 +1,184 @@
|
|||
/* tailwind */
|
||||
@import url('http://fonts.cdnfonts.com/css/lato');
|
||||
@tailwind base;
|
||||
|
||||
@tailwind components;
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
/* global */
|
||||
|
||||
html {
|
||||
@apply !bg-base-200 scroll-smooth overflow-x-hidden overflow-y-scroll;
|
||||
font-family: Lato, pingfang sc, microsoft yahei, sans-serif;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-primary/20;
|
||||
}
|
||||
|
||||
/* .urara-prose */
|
||||
|
||||
.urara-prose {
|
||||
@apply !max-w-none;
|
||||
}
|
||||
|
||||
/* .urara-prose heading */
|
||||
|
||||
.urara-prose > :is(h1, h2, h3, h4, h5) > a {
|
||||
@apply no-underline font-bold;
|
||||
}
|
||||
|
||||
.urara-prose > :is(h1, h2, h3, h4, h5) > a::after {
|
||||
@apply pl-2 text-base-200 transition-all content-['#'];
|
||||
}
|
||||
|
||||
.urara-prose > :is(h1, h2, h3, h4, h5):hover > a::after {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
/* .urara-prose table */
|
||||
|
||||
.urara-prose div > table > thead {
|
||||
@apply border-0;
|
||||
}
|
||||
|
||||
.urara-prose div > table > thead > tr > th {
|
||||
@apply !relative;
|
||||
}
|
||||
|
||||
/* .urara-prose a */
|
||||
|
||||
.urara-prose :is(p, li) > a {
|
||||
@apply bg-[length:100%_0.2em] hover:bg-[length:100%_100%] bg-[position:0_88%] bg-gradient-to-t from-secondary/50 to-primary/25 bg-no-repeat transition-all ease-in-out !no-underline;
|
||||
}
|
||||
|
||||
/* .urara-prose misc */
|
||||
|
||||
.urara-prose > p img {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.urara-prose :is(p, li) > code {
|
||||
@apply bg-base-200 px-2;
|
||||
}
|
||||
|
||||
.urara-prose li > input {
|
||||
@apply checkbox checkbox-xs;
|
||||
}
|
||||
|
||||
.urara-prose kbd {
|
||||
@apply kbd;
|
||||
}
|
||||
|
||||
.urara-prose hr {
|
||||
@apply border-none divider;
|
||||
}
|
||||
|
||||
/* footer a */
|
||||
|
||||
footer a {
|
||||
@apply !no-underline hover:text-primary hover:!underline transition-all;
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
@apply blur-sm hover:blur-none active:blur-none transition-all select-all;
|
||||
}
|
||||
|
||||
/* .prose pre */
|
||||
|
||||
.prose pre {
|
||||
@apply mockup-code !bg-neutral min-w-0;
|
||||
}
|
||||
|
||||
.prose pre:not(.shiki) {
|
||||
@apply bg-neutral text-neutral-content;
|
||||
}
|
||||
|
||||
.prose pre:not(.shiki)::before {
|
||||
@apply sticky -left-5 -ml-5;
|
||||
}
|
||||
|
||||
/* .urara-prose pre */
|
||||
|
||||
.urara-prose > pre {
|
||||
@apply -mx-8 rounded-none pb-0;
|
||||
}
|
||||
|
||||
.urara-prose > pre > div.code-container {
|
||||
@apply pb-5 overflow-x-auto;
|
||||
}
|
||||
|
||||
/* shiki */
|
||||
|
||||
pre.shiki {
|
||||
@apply px-0;
|
||||
}
|
||||
|
||||
pre.shiki::before {
|
||||
@apply sticky;
|
||||
}
|
||||
|
||||
pre.shiki > div.code-title {
|
||||
@apply absolute -mt-10 ml-20 pt-1.5 pl-1.5 opacity-50;
|
||||
}
|
||||
|
||||
pre.shiki .language-id {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
pre.shiki > .code-container {
|
||||
@apply overflow-auto;
|
||||
}
|
||||
|
||||
:is(pre.shiki[text='true'], pre.shiki[svelte='true']) > div.code-container {
|
||||
@apply mx-5;
|
||||
}
|
||||
|
||||
pre.shiki:not([text='true'], [svelte='true']) > .code-container > code > div.line > span:first-child {
|
||||
@apply pl-5;
|
||||
}
|
||||
|
||||
pre.shiki:not([text='true'], [svelte='true']) > .code-container > code > div.line > span:last-child {
|
||||
@apply pr-5;
|
||||
}
|
||||
|
||||
pre.shiki div.dim {
|
||||
@apply opacity-50 transition-opacity;
|
||||
}
|
||||
|
||||
pre.shiki:hover div.dim {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
pre.shiki div.highlight::before {
|
||||
@apply bg-warning/20 absolute content-[''] w-full h-6;
|
||||
}
|
||||
|
||||
pre.twoslash data-lsp {
|
||||
@apply border-b border-dashed border-transparent transition-all;
|
||||
}
|
||||
|
||||
pre.twoslash:hover data-lsp {
|
||||
@apply border-neutral-content/30;
|
||||
}
|
||||
|
||||
pre.twoslash data-lsp:hover::before {
|
||||
@apply content-[attr(lsp)] absolute rounded translate-y-5 bg-neutral-focus text-neutral-content font-mono whitespace-pre-wrap transition-all px-2 py-1 z-50;
|
||||
}
|
||||
|
||||
/* your code here */
|
||||
|
||||
.urara-prose blockquote {
|
||||
@apply font-normal text-current not-italic before:content-['“'];
|
||||
border-left: 4px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.urara-prose blockquote:before {
|
||||
vertical-align: -0.4em;
|
||||
@apply mr-2 text-5xl leading-3 italic font-serif opacity-25;
|
||||
}
|
||||
|
||||
.urara-prose blockquote p {
|
||||
@apply inline opacity-80 before:content-[''] after:content-[''];
|
||||
}
|
88
src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,88 @@
|
|||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
import { FFFBase, FFFExtra } from 'fff-flavored-frontmatter'
|
||||
|
||||
interface ImportMetaEnv extends Readonly<Record<string, string>> {
|
||||
readonly URARA_SITE_DOMAIN?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
glob<Module = { [key: string]: unknown }>(pattern: string): Record<string, Module>
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Urara {
|
||||
namespace Post {
|
||||
type Frontmatter = Omit<FFFBase, 'created' | 'updated' | 'image' | 'audio' | 'video' | 'flags'> &
|
||||
Pick<FFFExtra, 'in_reply_to'> & {
|
||||
/**
|
||||
* post type.
|
||||
* @remarks auto-generated
|
||||
*/
|
||||
type: 'article' | 'note' | 'photo' | 'reply' | 'audio' | 'video' | 'like' | 'repost' | 'bookmark'
|
||||
/**
|
||||
* post layout.
|
||||
*/
|
||||
layout?: 'article' | 'note' | 'photo' | 'reply'
|
||||
/**
|
||||
* post path.
|
||||
* @remarks auto-generated
|
||||
*/
|
||||
path: string
|
||||
/**
|
||||
* post slug.
|
||||
* @remarks auto-generated
|
||||
*/
|
||||
slug: string
|
||||
/**
|
||||
* table of contents.
|
||||
* @remarks auto-generated, article-only, set to `false` to disable
|
||||
*/
|
||||
toc?: false | Toc[]
|
||||
/**
|
||||
* the created date of the post.
|
||||
* @remarks auto-generated or set manually
|
||||
*/
|
||||
created: string
|
||||
/**
|
||||
* the updated date of the post.
|
||||
* @remarks auto-generated or set manually
|
||||
*/
|
||||
updated: string
|
||||
/**
|
||||
* the featured image for article, or image for "photo" / "multi-photo" posts.
|
||||
* @remarks currently only supports string
|
||||
*/
|
||||
image?: string
|
||||
/** enable some advanced features.
|
||||
* @property hidden - deprecated, transfer to `unlisted`
|
||||
* @property unlisted - hide this post from the homepage and feed.
|
||||
* @property bridgy-fed - add a link to Bridgy Fed in the post. https://fed.brid.gy/
|
||||
* @property bridgy-{target} - add a link to Bridgy in the post. https://brid.gy/publish/{target}
|
||||
*/
|
||||
flags?: string[]
|
||||
}
|
||||
type Toc = {
|
||||
depth: number
|
||||
title?: string
|
||||
slug?: string
|
||||
children?: Toc[]
|
||||
}
|
||||
interface Module {
|
||||
default: {
|
||||
render: () => {
|
||||
html: string
|
||||
head: string
|
||||
css: {
|
||||
code: string
|
||||
}
|
||||
}
|
||||
}
|
||||
metadata: Frontmatter
|
||||
}
|
||||
}
|
||||
type Post = Post.Frontmatter & { html?: string }
|
||||
type Page = { title?: string; path: string }
|
||||
}
|
||||
}
|
17
src/app.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head prefix="og: https://ogp.me/ns#">
|
||||
<meta charset="utf-8" />
|
||||
<meta name="generator" content="gh:importantimport/urara" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="/manifest.webmanifest" />
|
||||
<link rel="alternate" type="application/feed+json" href="/feed.json" />
|
||||
<link rel="alternate" type="application/atom+xml" href="/atom.xml" />
|
||||
<link rel="sitemap" type="application/xml" href="/sitemap.xml" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="https://schema.org/WebPage">
|
||||
%sveltekit.body%
|
||||
</body>
|
||||
</html>
|
7
src/hooks.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import type { Handle } from '@sveltejs/kit'
|
||||
import { site } from '$lib/config/site'
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) =>
|
||||
await resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('<html lang="en">', `<html lang="${site.lang ?? 'en'}">`)
|
||||
})
|
3
src/lib/components/actions/1-reply.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<a href="#post-comment" class="btn btn-lg btn-circle btn-ghost bg-base-100 shadow-lg hover:shadow-xl">
|
||||
<span class="i-heroicons-outline-chat-alt-2" />
|
||||
</a>
|
12
src/lib/components/actions/2-translate.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
export let post: Urara.Post
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://translate.google.com/translate?sl=auto&tl=${
|
||||
navigator.languages ? navigator.languages[0] : navigator.language
|
||||
}&u=${site.protocol + site.domain + post.path}`}
|
||||
class="btn btn-lg btn-circle btn-ghost bg-base-100 shadow-lg hover:shadow-xl">
|
||||
<span class="i-heroicons-outline-translate" />
|
||||
</a>
|
12
src/lib/components/actions/3-share.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
export let post: Urara.Post
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={`https://www.addtoany.com/share#url=${site.protocol + site.domain + post.path}&title=${encodeURI(
|
||||
post.title ?? post.path.slice(1)
|
||||
)}`}
|
||||
class="btn btn-lg btn-circle btn-ghost bg-base-100 shadow-lg hover:shadow-xl">
|
||||
<span class="i-heroicons-outline-share" />
|
||||
</a>
|
40
src/lib/components/comments/giscus.svelte
Normal file
|
@ -0,0 +1,40 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { site } from '$lib/config/site'
|
||||
import type { GiscusConfig } from '$lib/types/post'
|
||||
export let config: GiscusConfig
|
||||
|
||||
onMount(() => {
|
||||
const giscus = document.createElement('script')
|
||||
Object.entries({
|
||||
src: config.src ?? 'https://giscus.app/client.js',
|
||||
'data-repo': config.repo,
|
||||
'data-repo-id': config.repoID,
|
||||
'data-category': config.category ?? '',
|
||||
'data-category-id': config.categoryID,
|
||||
'data-mapping': 'pathname',
|
||||
'data-reactions-enabled': config.reactionsEnabled === false ? '0' : '1',
|
||||
'data-input-position': config.inputPosition ?? 'bottom',
|
||||
'data-theme': config.theme ?? 'preferred_color_scheme',
|
||||
'data-lang': config.lang ?? site.lang ?? 'en',
|
||||
'data-loading': config.loading ?? '',
|
||||
crossorigin: 'anonymous',
|
||||
async: ''
|
||||
}).forEach(([key, value]) => giscus.setAttribute(key, value))
|
||||
setTimeout(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
document.getElementById('giscus-loading')!.remove()
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document.getElementById('giscus')!, {
|
||||
childList: true
|
||||
})
|
||||
document.getElementById('giscus-container')!.appendChild(giscus)
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div id="giscus-container">
|
||||
<button id="giscus-loading" class="btn btn-lg flex mx-auto my-4 btn-ghost btn-circle loading" />
|
||||
<div id="giscus" class="giscus" />
|
||||
</div>
|
33
src/lib/components/comments/utterances.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import type { UtterancesConfig } from '$lib/types/post'
|
||||
export let config: UtterancesConfig
|
||||
|
||||
onMount(() => {
|
||||
const utterances = document.createElement('script')
|
||||
Object.entries({
|
||||
src: config.src ?? 'https://utteranc.es/client.js',
|
||||
repo: config.repo,
|
||||
'issue-term': 'pathname',
|
||||
label: config.label ?? '',
|
||||
theme: config.theme ?? 'preferred-color-scheme',
|
||||
crossorigin: 'anonymous',
|
||||
async: ''
|
||||
}).forEach(([key, value]) => utterances.setAttribute(key, value))
|
||||
setTimeout(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
document.getElementById('utterances-loading')!.remove()
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document.getElementById('utterances')!, {
|
||||
childList: true
|
||||
})
|
||||
document.getElementById('utterances-container')!.appendChild(utterances)
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div id="utterances-container">
|
||||
<button id="utterances-loading" class="btn btn-lg flex mx-auto my-4 btn-ghost btn-circle loading" />
|
||||
<div id="utterances" class="utterances" />
|
||||
</div>
|
117
src/lib/components/comments/waline.svelte
Normal file
|
@ -0,0 +1,117 @@
|
|||
<!-- <script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { site } from '$lib/config/site'
|
||||
import type { WalineConfig } from '$lib/types/post'
|
||||
|
||||
export let post: Urara.Post
|
||||
export let config: WalineConfig
|
||||
|
||||
onMount(() => {
|
||||
const waline = document.createElement('script')
|
||||
const [c, s] = [document.createElement('script'), document.createElement('script')]
|
||||
c.id = 'disqus_config'
|
||||
c.type = 'application/javascript'
|
||||
console.log(waline)
|
||||
Object.entries({
|
||||
src: 'https://unpkg.com/@waline/client@v2/dist/waline.js',
|
||||
serverURL: config.serverURL,
|
||||
path: config.path ?? post.path ?? window.location.pathname,
|
||||
lang: config.lang ?? 'en',
|
||||
emoji: config.emoji ?? ['//unpkg.com/@waline/emojis@1.0.1/weibo'],
|
||||
dark: config.dark ?? false,
|
||||
meta: config.meta ?? ['nick', 'mail', 'link'],
|
||||
requiredMeta: config.requiredMeta ?? [],
|
||||
login: config.login ?? 'enable',
|
||||
wordLimit: config.wordLimit ?? 0,
|
||||
pageSize: config.pageSize ?? 10,
|
||||
imageUploader: config.imageUploader,
|
||||
highlighter: config.highlighter,
|
||||
texRender: config.texRender,
|
||||
copyright: config.copyright ?? true,
|
||||
crossorigin: 'anonymous',
|
||||
async: ''
|
||||
}).forEach(([key, value]) => waline.setAttribute(key, value))
|
||||
setTimeout(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
document.getElementById('giscus-loading').remove()
|
||||
observer.disconnect()
|
||||
})
|
||||
observer.observe(document.getElementById('giscus'), {
|
||||
childList: true
|
||||
})
|
||||
document.getElementById('giscus-container').appendChild(waline)
|
||||
}, 1000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/@waline/client@v2/dist/waline.css" />
|
||||
<div id="waline" class="waline-container" />
|
||||
|
||||
<style>
|
||||
.waline-container {
|
||||
background-color: var(--card-background);
|
||||
border-radius: var(--card-border-radius);
|
||||
box-shadow: var(--shadow-l1);
|
||||
padding: 2%;
|
||||
}
|
||||
.waline-container .vcount {
|
||||
color: var(--card-text-color-main);
|
||||
}
|
||||
.v[data-class='v'] .vcard {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
padding-bottom: 0.5em;
|
||||
border-bottom: 0; /*删掉回复下面的线*/
|
||||
}
|
||||
.v[data-class='v'] .vcard .vquote {
|
||||
border-left: 1px solid rgba(237, 237, 237, 0.5);
|
||||
}
|
||||
@media (max-width: 580px) {
|
||||
.v[data-class='v'] .vheader .vheader-item:not(:last-child) {
|
||||
border-bottom: 1px solid rgba(237, 237, 237, 0.8); /*输入框分割线*/
|
||||
}
|
||||
}
|
||||
/*日间模式*/
|
||||
:root {
|
||||
--waline-theme-color: #34495e; /*主题色,提交按钮*/
|
||||
--waline-active-color: #246bb1; /*鼠标移到提交按钮上的颜色*/
|
||||
/* 徽章 */
|
||||
--waline-badge-color: #34495e; /*博主徽章色*/
|
||||
--waline-avatar-radius: 5px;
|
||||
--waline-avatar-size: 6rem;
|
||||
--waline-dark-grey: #34495e; /*ID颜色*/
|
||||
--waline-text-color: #34495e; /*字体颜色*/
|
||||
--waline-font-size: 1.7rem; /*字体大小颜色*/
|
||||
}
|
||||
/*夜间模式*/
|
||||
:root[data-scheme='dark'] {
|
||||
--waline-theme-color: #acc6e0;
|
||||
--waline-white: #34495e; /*按键字体颜色*/
|
||||
--waline-active-color: #8ab1d8;
|
||||
--waline-light-grey: #666;
|
||||
--waline-dark-grey: #acc6e0; /*ID颜色*/
|
||||
--waline-badge-color: #acc6e0;
|
||||
/* 布局颜色 */
|
||||
--waline-text-color: rgba(255, 255, 255, 0.7);
|
||||
--waline-bgcolor: #515151;
|
||||
--waline-bgcolor-light: #66696b; /*行内代码块颜色*/
|
||||
--waline-border-color: #9b9c9c;
|
||||
--waline-disable-bgcolor: #444;
|
||||
--waline-disable-color: #272727;
|
||||
/* 特殊颜色 */
|
||||
--waline-bq-color: #9b9c9c; /*quote*/
|
||||
/* 其他颜色 */
|
||||
--waline-info-bgcolor: #acc6e0;
|
||||
--waline-info-color: #9b9c9c;
|
||||
}
|
||||
.v[data-class='v'] .vcontent .vemoji {
|
||||
width: 2.2em; /*表情包大小修改*/
|
||||
margin: 0.25em;
|
||||
}
|
||||
.v[data-class='v'] .vheader {
|
||||
border-bottom: 1px solid rgba(237, 237, 237, 0.8); /*输入框分割线*/
|
||||
}
|
||||
.v[data-class='v'] .vpanel {
|
||||
border-radius: 8px; /*输入框圆角*/
|
||||
}
|
||||
</style> -->
|
205
src/lib/components/comments/webmention.svelte
Normal file
|
@ -0,0 +1,205 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { site } from '$lib/config/site'
|
||||
import type { WebmentionConfig } from '$lib/types/post'
|
||||
export let config: WebmentionConfig
|
||||
export let post: Urara.Post
|
||||
|
||||
interface WebmentionFeed {
|
||||
type: 'feed'
|
||||
name: 'Webmentions'
|
||||
children: WebmentionEntry[]
|
||||
}
|
||||
|
||||
interface WebmentionEntry {
|
||||
url: string
|
||||
author?: {
|
||||
name?: string
|
||||
photo?: string
|
||||
url?: string
|
||||
}
|
||||
content?: {
|
||||
html?: string
|
||||
text?: string
|
||||
}
|
||||
rsvp?: string
|
||||
published?: string
|
||||
'wm-received': string
|
||||
'wm-source': string
|
||||
'wm-target': string
|
||||
'wm-id': number
|
||||
'wm-property': 'in-reply-to' | 'like-of' | 'repost-of' | 'bookmark-of' | 'mention-of' | 'rsvp'
|
||||
'wm-private': boolean
|
||||
}
|
||||
|
||||
let [page, loaded, end, mentions, sortDirUp]: [number, boolean, boolean, WebmentionEntry[], boolean] = [
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
[],
|
||||
config?.sortDir === 'up' ? true : false
|
||||
]
|
||||
|
||||
const load = async () =>
|
||||
await fetch(
|
||||
`https://webmention.io/api/mentions.jf2?page=${page}&per-page=${config?.perPage ?? '20'}&sort-by=${
|
||||
config?.sortBy ?? 'created'
|
||||
}&sort-dir=${sortDirUp ? 'up' : 'down'}${
|
||||
config?.property && config.property.forEach(wmProperty => `&wm-property=${wmProperty}`)
|
||||
}&target[]=${site.protocol + site.domain + post.path}&target[]=${site.protocol + site.domain + post.path}/`
|
||||
)
|
||||
.then(res => res.json())
|
||||
.then((feed: WebmentionFeed) => {
|
||||
if (feed.children.length < 10) end = true
|
||||
feed = {
|
||||
...feed,
|
||||
children: feed.children.filter(
|
||||
(entry: WebmentionEntry) => !config.blockList?.includes(new URL(entry['wm-source']).origin)
|
||||
)
|
||||
}
|
||||
if (feed.children.length > 0) mentions = [...mentions, ...feed.children]
|
||||
page++
|
||||
loaded = true
|
||||
})
|
||||
|
||||
const reset = async () => {
|
||||
page = 0
|
||||
loaded = false
|
||||
end = false
|
||||
mentions = []
|
||||
await load()
|
||||
}
|
||||
|
||||
onMount(() => load())
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex">
|
||||
<p class="flex-1 m-auto italic opacity-50">
|
||||
<!-- {`Sort by=${config?.sortBy ?? 'Created'}&sort-dir=${sortDirUp ? 'up' : 'down'}`} -->
|
||||
{`Sort ${sortDirUp ? 'up' : 'down'}`}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm float-right"
|
||||
on:click={() => {
|
||||
sortDirUp = !sortDirUp
|
||||
reset()
|
||||
}}>
|
||||
{#if sortDirUp === true}
|
||||
<span class="i-heroicons-outline-sort-ascending" />
|
||||
{:else}
|
||||
<span class="i-heroicons-outline-sort-descending" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#key mentions}
|
||||
{#each mentions as mention}
|
||||
{@const [wmProperty, borderColor, textColor, tooltipColor] = {
|
||||
'in-reply-to': ['💬 Replied', 'border-primary/50', 'text-primary', 'tooltip-primary'],
|
||||
'like-of': ['❤️ liked', 'border-secondary/50', 'text-secondary', 'tooltip-secondary'],
|
||||
'repost-of': ['🔄 Reposted', 'border-accent/50', 'text-accent', 'tooltip-accent'],
|
||||
'bookmark-of': ['⭐️ bookmarked', 'border-neutral/50', 'text-neutral', 'tooltip-neutral'],
|
||||
'mention-of': ['💬 mentioned', 'border-base-300/50', 'text-base-content', 'tooltip-base-content'],
|
||||
rsvp: [
|
||||
`📅 RSVPed ${
|
||||
mention.rsvp &&
|
||||
{
|
||||
yes: '✅',
|
||||
no: '❌',
|
||||
interested: '💡',
|
||||
maybe: '💭'
|
||||
}[mention.rsvp]
|
||||
}`,
|
||||
'border-warning/50',
|
||||
'text-warning',
|
||||
'tooltip-warning'
|
||||
]
|
||||
}[mention['wm-property']]}
|
||||
{#if mention.url !== null}
|
||||
<div class="{borderColor} border-2 rounded-box p-4">
|
||||
<div class="flex bg-base-200 rounded-btn">
|
||||
{#if mention?.author?.photo}
|
||||
<img
|
||||
class="w-12 h-12 flex-none rounded-btn"
|
||||
src={mention.author.photo}
|
||||
alt={mention.author?.name ?? new URL(mention.url).host}
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
{/if}
|
||||
<div class="flex-1 px-4 py-2 m-auto">
|
||||
<p>
|
||||
{#if mention?.author?.url}
|
||||
<a class="font-semibold {textColor} hover:underline" href={mention.author.url}>
|
||||
{mention.author?.name ?? new URL(mention.url).host}
|
||||
</a>
|
||||
{:else}
|
||||
{mention?.author?.name ?? new URL(mention.url).host}
|
||||
{/if}
|
||||
<a class="{textColor} hover:underline" href={mention['wm-source']}>
|
||||
{wmProperty}
|
||||
</a>
|
||||
this post on
|
||||
<span
|
||||
class="tooltip tooltip-bottom xl:tooltip-right {tooltipColor}"
|
||||
data-tip={new Date(mention.published ?? mention['wm-received']).toLocaleString()}>
|
||||
{mention.published ? mention.published.slice(0, 10) : mention['wm-received'].slice(0, 10)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if mention.content}
|
||||
<div class="prose max-w-none break-words mt-4">
|
||||
<p>{@html mention.content?.html ?? mention.content?.text}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/key}
|
||||
{#if loaded === true}
|
||||
{#if end !== true}
|
||||
<button
|
||||
on:click={() => {
|
||||
loaded = false
|
||||
load()
|
||||
}}
|
||||
class="btn btn-primary btn-block">
|
||||
LOAD
|
||||
</button>
|
||||
{:else if config?.form !== true}
|
||||
<div class="divider mt-0 -mb-2">END</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<button id="webmention-loading" class="btn btn-lg btn-block flex btn-ghost loading" />
|
||||
{/if}
|
||||
{#if config?.form === true}
|
||||
<form id="webmention-form" method="post" action="https://webmention.io/{config.username}/webmention">
|
||||
<input type="hidden" name="target" value={site.protocol + site.domain + post.path} />
|
||||
<div class="label gap-4">
|
||||
<span class="label-text">send webmentions here:</span>
|
||||
{#if config?.commentParade === true}
|
||||
<span class="label-text-alt text-right">
|
||||
or <a
|
||||
class="hover:!text-primary"
|
||||
href="https://quill.p3k.io/?dontask=1&me=https://commentpara.de/&reply={encodeURI(
|
||||
site.protocol + site.domain + post.path
|
||||
)}">
|
||||
comment anonymously
|
||||
</a>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="input input-bordered focus:input-primary w-full"
|
||||
type="text"
|
||||
id="reply-url"
|
||||
name="source"
|
||||
placeholder="https://example.com/my-post" />
|
||||
</div>
|
||||
<button class="btn btn-primary flex-none mt-auto" type="submit" id="webmention-submit">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
35
src/lib/components/extra/alert.svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
export let title: string | undefined = undefined
|
||||
export let description: string | undefined = undefined
|
||||
export let status: 'info' | 'success' | 'warning' | 'error' | undefined = undefined
|
||||
</script>
|
||||
|
||||
<div
|
||||
class:alert-info={status === 'info'}
|
||||
class:alert-success={status === 'success'}
|
||||
class:alert-warning={status === 'warning'}
|
||||
class:alert-error={status === 'error'}
|
||||
class="alert flex-col shadow-inner my-4">
|
||||
<div class="mr-auto">
|
||||
{#if status === 'success'}
|
||||
<span class="i-heroicons-outline-check-circle" />
|
||||
{:else if status === 'warning'}
|
||||
<span class="i-heroicons-outline-exclamation-circle" />
|
||||
{:else if status === 'error'}
|
||||
<span class="i-heroicons-outline-x-circle" />
|
||||
{:else}
|
||||
<span class="i-heroicons-outline-information-circle" />
|
||||
{/if}
|
||||
<div>
|
||||
<div class:font-bold={description}>{title}</div>
|
||||
{#if description}
|
||||
<div class="text-xs">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if $$slots.default}
|
||||
<div class="block w-full">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
64
src/lib/components/extra/follow.svelte
Normal file
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
let status: string = ''
|
||||
let statusText: string = ''
|
||||
let input: string = ''
|
||||
const follow = async (event: Event, account = new FormData(event.target as HTMLFormElement).get('account') as string) =>
|
||||
await fetch(
|
||||
account.startsWith('@')
|
||||
? `https://${account.split('@')[2]}/.well-known/webfinger?resource=acct:${account.slice(1)}`
|
||||
: `https://${account.split('@')[1]}/.well-known/webfinger?resource=acct:${account}`,
|
||||
{
|
||||
headers: { Accept: 'application/jrd+json' }
|
||||
}
|
||||
)
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
status = 'success'
|
||||
return res.json()
|
||||
} else {
|
||||
status = 'error'
|
||||
statusText = res.status + res.statusText
|
||||
throw Error(res.status + res.statusText)
|
||||
}
|
||||
})
|
||||
.then(
|
||||
({ links }) => links.find((link: { rel: string }) => link.rel === 'http://ostatus.org/schema/1.0/subscribe').template
|
||||
)
|
||||
.then(template => (window.location.href = template.replace('{uri}', `sevichecc@kongwoo.icu`)))
|
||||
.catch(error => console.error(error))
|
||||
$: if (input)
|
||||
input.length < 5 ? (status = '') : input.includes('@') && input.includes('.') ? (status = 'success') : (status = 'warning')
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="remote-follow" class="modal-toggle" />
|
||||
<label for="remote-follow" class="modal modal-bottom sm:modal-middle cursor-pointer">
|
||||
<div class="modal-box relative" for="">
|
||||
<form on:submit|preventDefault={follow} class="form-control gap-2">
|
||||
<div class="label py-0">
|
||||
<span class="label-text">Your fediverse account ID:</span>
|
||||
</div>
|
||||
<label class="input-group">
|
||||
<input
|
||||
bind:value={input}
|
||||
type="text"
|
||||
id="account"
|
||||
name="account"
|
||||
placeholder="username@instance.tld"
|
||||
class:input-success={status === 'success'}
|
||||
class:input-warning={status === 'warning'}
|
||||
class:input-error={status === 'error'}
|
||||
class="input input-bordered transition-all flex-1" />
|
||||
<button type="submit" class="btn btn-square">
|
||||
<span class="i-heroicons-outline-paper-airplane rotate-90" />
|
||||
</button>
|
||||
</label>
|
||||
{#if statusText}
|
||||
<div class="label py-0">
|
||||
<span class="label-text-alt text-error">
|
||||
{statusText}{#if statusText === '404'}: Couldn't find user{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</label>
|
50
src/lib/components/extra/friend.svelte
Normal file
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { Friend } from '$lib/config/friends'
|
||||
import Footer from '$lib/components/footer.svelte'
|
||||
export let item: unknown
|
||||
let friend = item as unknown as Friend
|
||||
</script>
|
||||
|
||||
{#if friend.id === 'footer'}
|
||||
<Footer rounded={true} class="p-4 md:p-8" />
|
||||
{:else if friend.html}
|
||||
<a id={friend.id} rel={friend.rel} href={friend.link} class="h-card u-url">
|
||||
{@html friend.html}
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
id={friend.id}
|
||||
rel={friend.rel}
|
||||
href={friend.link}
|
||||
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow h-card u-url">
|
||||
<div class="absolute text-4xl font-bold opacity-5 rotate-6 leading-tight top-4">
|
||||
{friend.name ?? ''}
|
||||
<br />
|
||||
{friend.title ?? ''}
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if friend.avatar}
|
||||
<div class="avatar {friend.class?.avatar} shrink-0 w-16 mb-auto">
|
||||
<img class="{friend.class?.img ?? 'rounded-xl'} u-photo" src={friend.avatar} alt={friend.title} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="avatar {friend.class?.avatar} placeholder mb-auto">
|
||||
<div class="{friend.class?.img ?? 'bg-neutral-focus text-neutral-content shadow-inner rounded-xl'} w-16">
|
||||
<span class="text-3xl">{(friend.name ?? friend.title).charAt(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card-title flex-col gap-0 flex-1 items-end">
|
||||
<span class="text-right p-name">{friend.name ?? ''}</span>
|
||||
<span class="opacity-50 text-right">{friend.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if friend.descr}
|
||||
<div class="prose opacity-70 p-note">
|
||||
{friend.descr}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
83
src/lib/components/extra/github.svelte
Normal file
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts" context="module">
|
||||
export const prerender = true
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let user = undefined
|
||||
export let repo = undefined
|
||||
let info: {
|
||||
html_url: string
|
||||
description: string
|
||||
homepage?: string
|
||||
owner: { avatar_url: string }
|
||||
stargazers_count: any
|
||||
license?: { key?: any }
|
||||
}
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
onMount(async () => {
|
||||
info = await (await fetch(`https://api.github.com/repos/${user}/${repo}`)).json()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 !bg-base-200 my-4 ">
|
||||
<div class="p-6">
|
||||
{#key info}
|
||||
{#if info}
|
||||
<div class="flex">
|
||||
<div class="flex-initial pr-4">
|
||||
<div class="card-title mb-6 !text-3xl font-medium">
|
||||
<a rel="noopener noreferrer external" target="_blank" href={info.html_url}>
|
||||
{user}/
|
||||
<span class="font-semibold">{repo}</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="prose">
|
||||
{info.description}
|
||||
<br />
|
||||
<a rel="noopener noreferrer external" target="_blank" href={info.homepage}>{info.homepage}</a>
|
||||
</p>
|
||||
</div>
|
||||
<img class="w-20 h-20 ml-auto rounded-xl flex-initial" alt="owner_avatar" src={info.owner.avatar_url} />
|
||||
</div>
|
||||
<div class="card-actions -ml-2">
|
||||
<button class="btn btn-sm btn-ghost">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
data-view-component="true"
|
||||
class="inline-block w-4 h-4 mr-2 fill-current">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z" />
|
||||
</svg>
|
||||
{info.stargazers_count}
|
||||
</button>
|
||||
{#if info.license}
|
||||
<a class="btn btn-sm btn-ghost" href="https://choosealicense.com/licenses/{info.license.key}">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
data-view-component="true"
|
||||
class="inline-block w-4 h-4 mr-2 fill-current">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.75.75a.75.75 0 00-1.5 0V2h-.984c-.305 0-.604.08-.869.23l-1.288.737A.25.25 0 013.984 3H1.75a.75.75 0 000 1.5h.428L.066 9.192a.75.75 0 00.154.838l.53-.53-.53.53v.001l.002.002.002.002.006.006.016.015.045.04a3.514 3.514 0 00.686.45A4.492 4.492 0 003 11c.88 0 1.556-.22 2.023-.454a3.515 3.515 0 00.686-.45l.045-.04.016-.015.006-.006.002-.002.001-.002L5.25 9.5l.53.53a.75.75 0 00.154-.838L3.822 4.5h.162c.305 0 .604-.08.869-.23l1.289-.737a.25.25 0 01.124-.033h.984V13h-2.5a.75.75 0 000 1.5h6.5a.75.75 0 000-1.5h-2.5V3.5h.984a.25.25 0 01.124.033l1.29.736c.264.152.563.231.868.231h.162l-2.112 4.692a.75.75 0 00.154.838l.53-.53-.53.53v.001l.002.002.002.002.006.006.016.015.045.04a3.517 3.517 0 00.686.45A4.492 4.492 0 0013 11c.88 0 1.556-.22 2.023-.454a3.512 3.512 0 00.686-.45l.045-.04.01-.01.006-.005.006-.006.002-.002.001-.002-.529-.531.53.53a.75.75 0 00.154-.838L13.823 4.5h.427a.75.75 0 000-1.5h-2.234a.25.25 0 01-.124-.033l-1.29-.736A1.75 1.75 0 009.735 2H8.75V.75zM1.695 9.227c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327l-1.305 2.9zm10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327l-1.305 2.9z" />
|
||||
</svg>
|
||||
{info.license.key}
|
||||
</a>
|
||||
{/if}
|
||||
<button class="btn btn-sm btn-circle btn-ghost ml-auto">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" version="1.1" data-view-component="true" class="w-6 h-6 fill-current">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
12
src/lib/components/extra/mockup_code.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts" context="module">
|
||||
export const prerender = true
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let code: string
|
||||
export let lang = 'text'
|
||||
export let theme = 'material-default'
|
||||
export let highlightedLines = undefined
|
||||
</script>
|
||||
|
||||
<p>TODO {code} {lang} {theme} {highlightedLines}</p>
|
18
src/lib/components/extra/picture.svelte
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { dev } from '$app/env'
|
||||
let className = undefined
|
||||
export { className as class }
|
||||
export let src = undefined
|
||||
export let alt = undefined
|
||||
export let loading: 'lazy' | 'eager' = 'lazy'
|
||||
export let decoding: 'async' | 'sync' | 'auto' = 'async'
|
||||
let [name, ext] = [src.split('.').slice(0, -1).join('.'), src.split('.').pop()]
|
||||
</script>
|
||||
|
||||
<picture>
|
||||
{#if !dev}
|
||||
<source srcset="{name}_768.{ext} 1x" media="(max-width: 425px)" />
|
||||
<source srcset="{name}_768.{ext} 1x, {src} 2x" media="(min-width: 425px)" />
|
||||
{/if}
|
||||
<img itemprop="image" class={className} {src} {alt} {loading} {decoding} />
|
||||
</picture>
|
7
src/lib/components/extra/post_theme.svelte
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
export let theme = undefined
|
||||
</script>
|
||||
|
||||
{@html `
|
||||
${theme ?? ''}
|
||||
`}
|
36
src/lib/components/extra/profile.svelte
Normal file
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts" context="module">
|
||||
export const prerender = true
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
export let avatar: string
|
||||
export let name: string
|
||||
export let subname: string
|
||||
export let bio: string
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-auto min-h-48 rounded-box overflow-hidden bg-gradient-to-b from-primary to-secondary text-primary-content transition-shadow duration-200 shadow-xl hover:shadow-2xl p-4 md:p-8 my-4">
|
||||
<div class="absolute -top-4 opacity-10 text-[12rem] text-neutral leading-tight rotate-[30deg]">
|
||||
{name ?? site.author.name}
|
||||
</div>
|
||||
<div class="avatar mb-4">
|
||||
<div class="rounded-full border-2 border-white shadow-xl w-16 h-16">
|
||||
<img
|
||||
class="hover:rotate-[360deg] transition-transform duration-1000 ease-in-out m-0"
|
||||
src={avatar ?? site.author.avatar}
|
||||
alt={name ?? site.author.name}
|
||||
loading="lazy"
|
||||
decoding="async" />
|
||||
</div>
|
||||
</div>
|
||||
{#if subname}
|
||||
<div class="opacity-50">{subname}</div>
|
||||
{/if}
|
||||
<div class="text-2xl mb-2">{name ?? site.author.name}</div>
|
||||
{#if bio || site.author.bio}
|
||||
<div>{@html bio ?? site.author.bio}</div>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
42
src/lib/components/extra/projects.svelte
Normal file
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import type { Project } from '$lib/config/projects'
|
||||
import Footer from '$lib/components/footer.svelte'
|
||||
export let item: unknown
|
||||
let project = item as unknown as Project
|
||||
let tags = project.tags
|
||||
</script>
|
||||
|
||||
{#if project.id === 'footer'}
|
||||
<Footer rounded={true} class="max-w-4xl mx-auto p-4 md:p-8" />
|
||||
{:else}
|
||||
<a
|
||||
id={project.id}
|
||||
href={project.link}
|
||||
class="card mx-auto max-w-4xl bg-base-100 shadow-xl transition-shadow mb-7 h-card u-url hover:shadow-2xl">
|
||||
<div class="absolute text-5xl font-bold opacity-5 rotate-6 leading-tight top-2 right-0">
|
||||
{project.feature}
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="flex flex-col md:flex-row items-start gap-4">
|
||||
<div class="mb-auto aspect-video w-full max-w-full shrink-0 md:max-w-xs">
|
||||
<img class="rounded-md " src={project.img} alt={project.description} />
|
||||
</div>
|
||||
<div class="card-title flex-1 flex-col items-start gap-4">
|
||||
<div>
|
||||
<h2 class="p-name text-left text-2xl mb-2">{project.name}</h2>
|
||||
<div class="mb-3 text-base font-normal">
|
||||
{#each tags as tag}
|
||||
<span class="btn btn-sm btn-ghost normal-case border-dotted border-base-content/20 border-2 my-1 mr-1">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-left text-base font-normal opacity-70">
|
||||
{@html project.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
28
src/lib/components/extra/youtube.svelte
Normal file
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
export let id = undefined
|
||||
export let list = undefined
|
||||
export let playlist = undefined
|
||||
export let start = undefined
|
||||
export let autoplay = false
|
||||
export let disablekb = false
|
||||
export let controls = true
|
||||
export let fs = true
|
||||
export let loop = false
|
||||
|
||||
const src = `https://www.youtube.com/embed/${id}?${list ? `listType=playlist&list=${list}&` : ''}${
|
||||
playlist ? `playlist=${playlist}&` : ''
|
||||
}${start ? `start=${start}` : ''}${autoplay ? 'autoplay=1&' : ''}${disablekb ? 'disablekb=1&' : ''}${
|
||||
controls ? '' : 'controls=0&'
|
||||
}${fs ? '' : 'fs=0&'}${loop ? 'loop=1' : ''}`
|
||||
</script>
|
||||
|
||||
<div class="relative pb-[56.25%] mb-2">
|
||||
<iframe
|
||||
{src}
|
||||
class="absolute w-full h-full"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
loading="lazy"
|
||||
allowfullscreen />
|
||||
</div>
|
46
src/lib/components/footer.svelte
Normal file
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
import { footer as footerConfig } from '$lib/config/general'
|
||||
let className: string | undefined = undefined
|
||||
export { className as class }
|
||||
export let sticky: boolean = false
|
||||
export let rounded: boolean = false
|
||||
</script>
|
||||
|
||||
<footer
|
||||
id="footer"
|
||||
class="footer footer-center bg-base-300 text-base-content shadow-inner p-8 {rounded
|
||||
? 'rounded-box'
|
||||
: 'md:rounded-box'} {sticky ? 'sticky bottom-0 z-0 md:static' : ''} {className ?? ''}">
|
||||
<div class="prose">
|
||||
<p>
|
||||
{#if footerConfig.nav}
|
||||
{#each footerConfig.nav as { text, link }, i}
|
||||
<a href={link} rel="noopener external" target="_blank">{text}</a>
|
||||
{#if i + 1 < footerConfig.nav.length}
|
||||
<span class="mr-1">·</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<br />
|
||||
{/if}
|
||||
Copyright © {footerConfig.since && footerConfig.since !== new Date().toJSON().substring(0, 4)
|
||||
? `${footerConfig.since} - ${new Date().toJSON().substring(0, 4)}`
|
||||
: new Date().toJSON().substring(0, 4)}
|
||||
{site.author.name}
|
||||
<br />
|
||||
Powered by
|
||||
<a
|
||||
rel="noopener external"
|
||||
target="_blank"
|
||||
class="tooltip tooltip-secondary hover:text-secondary"
|
||||
data-tip="🌸 [δ] - Based on MDsveX & SvelteKit 🌸"
|
||||
href="https://github.com/importantimport/urara">
|
||||
Urara
|
||||
</a>
|
||||
{#if footerConfig.html}
|
||||
<br />
|
||||
{@html footerConfig.html}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
39
src/lib/components/head.svelte
Normal file
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { dev } from '$app/env'
|
||||
import { head } from '$lib/config/general'
|
||||
import { site } from '$lib/config/site'
|
||||
import OpenGraph from '$lib/components/head_opengraph.svelte'
|
||||
export let post: Urara.Post | undefined = undefined
|
||||
export let page: Urara.Page | undefined = undefined
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="author" content={site.author?.name} />
|
||||
{#if post}
|
||||
<link rel="canonical" href={site.protocol + site.domain + post.path} />
|
||||
{#if post.type === 'article'}
|
||||
<title>{post.title} | {site.title}</title>
|
||||
{:else if post.type === 'note'}
|
||||
<title>{post.summary ?? post.path.slice(1)} | {site.title}</title>
|
||||
{/if}
|
||||
{#if post.tags}<meta name="keywords" content={post.tags.join(', ')} />{/if}
|
||||
{#if post.summary}<meta name="description" content={post.summary} />{/if}
|
||||
{:else}
|
||||
<meta name="description" content={site.description} />
|
||||
<meta name="keywords" content={site.keywords?.join(', ')} />
|
||||
{#if page}
|
||||
<title>{page.title ?? page.path.slice(1)} | {site.title}</title>
|
||||
<link rel="canonical" href={site.protocol + site.domain + page.path} />
|
||||
{:else}
|
||||
<title>{site.subtitle ? `${site.title} - ${site.subtitle}` : site.title}</title>
|
||||
<link rel="canonical" href={site.protocol + site.domain} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if head.custom}
|
||||
{#each head.custom({ dev, post, page }) as tag}
|
||||
{@html tag}
|
||||
{/each}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<OpenGraph {post} {page} />
|
15
src/lib/components/head_icon.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { favicon, any } from '$lib/config/icon'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if favicon}
|
||||
<link rel="shortcut icon" href={favicon.src} sizes={favicon.sizes} type={favicon.type} />
|
||||
{/if}
|
||||
{#if any['180']}
|
||||
<link rel="apple-touch-icon" href={any['180'].src} sizes={any['180'].sizes} type={any['180'].type} />
|
||||
{/if}
|
||||
{#if any['192']}
|
||||
<link rel="icon" href={any['192'].src} sizes={any['192'].sizes} type={any['192'].type} />
|
||||
{/if}
|
||||
</svelte:head>
|
45
src/lib/components/head_opengraph.svelte
Normal file
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
import { any, maskable } from '$lib/config/icon'
|
||||
export let post: Urara.Post | undefined = undefined
|
||||
export let page: Urara.Page | undefined = undefined
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta property="og:site_name" content={site.title} />
|
||||
<meta property="og:locale" content={site.lang} />
|
||||
{#if post}
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:title" content={post.title ?? post.summary ?? post.path.slice(1)} />
|
||||
{#if post.summary}
|
||||
<meta property="og:description" content={post.summary} />
|
||||
{/if}
|
||||
{#if post.image}
|
||||
<meta property="og:image" content={site.protocol + site.domain + post.image} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
{:else}
|
||||
<meta property="og:image" content={maskable['512'].src ?? any['512'].src ?? any['192'].src} />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
{/if}
|
||||
{#if post.tags}
|
||||
{#each post.tags as tag}
|
||||
<meta property="article:tag" content={tag} />
|
||||
{/each}
|
||||
{/if}
|
||||
<meta property="og:url" content={site.protocol + site.domain + post.path} />
|
||||
<meta property="article:author" content={site.author.name} />
|
||||
<meta property="article:published_time" content={post.published ?? post.created} />
|
||||
<meta property="article:modified_time" content={post.updated ?? post.published ?? post.created} />
|
||||
{:else}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={maskable['512'].src ?? any['512'].src ?? any['192'].src} />
|
||||
<meta property="og:description" content={site.description} />
|
||||
{#if page}
|
||||
<meta property="og:title" content={page.title ?? page.path.slice(1)} />
|
||||
<meta property="og:url" content={site.protocol + site.domain + page.path} />
|
||||
{:else}
|
||||
<meta property="og:title" content={site.title} />
|
||||
<meta property="og:url" content={site.protocol + site.domain} />
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:head>
|
18
src/lib/components/head_relmeauth.svelte
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
import { head } from '$lib/config/general'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if site.author.github}
|
||||
<link rel="me" href="https://github.com/{site.author.github}" />
|
||||
{/if}
|
||||
{#if site.author.twitter}
|
||||
<link rel="me" href="https://twitter.com/{site.author.twitter}" />
|
||||
{/if}
|
||||
{#if head.relMe}
|
||||
{#each head.relMe as href}
|
||||
<link rel="me" {href} />
|
||||
{/each}
|
||||
{/if}
|
||||
</svelte:head>
|
19
src/lib/components/head_static.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { head } from '$lib/config/general'
|
||||
import { post } from '$lib/config/post'
|
||||
import Icon from '$lib/components/head_icon.svelte'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if head.me}
|
||||
{#each head.me as href}
|
||||
<link rel="me" {href} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if post.comment?.webmention?.username}
|
||||
<link rel="webmention" href="https://webmention.io/{post.comment.webmention.username}/webmention" />
|
||||
<link rel="pingback" href="https://webmention.io/{post.comment.webmention.username}/xmlrpc" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<Icon />
|
131
src/lib/components/header.svelte
Normal file
|
@ -0,0 +1,131 @@
|
|||
<script lang="ts">
|
||||
import { browser, dev } from '$app/env'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { site } from '$lib/config/site'
|
||||
import { theme } from '$lib/config/general'
|
||||
import { title as storedTitle } from '$lib/stores/title'
|
||||
import { header as headerConfig } from '$lib/config/general'
|
||||
import { hslToHex } from '$lib/utils/color'
|
||||
import Nav from '$lib/components/header_nav.svelte'
|
||||
import Search from '$lib/components/header_search.svelte'
|
||||
export let path: string
|
||||
let title: string
|
||||
let currentTheme: string
|
||||
let currentThemeColor: string
|
||||
let search: boolean = false
|
||||
let pin: boolean = true
|
||||
let percent: number
|
||||
let [scrollY, lastY] = [0, 0]
|
||||
|
||||
storedTitle.subscribe(storedTitle => (title = storedTitle as string))
|
||||
|
||||
$: if (browser && currentTheme) {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
currentThemeColor = hslToHex(
|
||||
...(getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--b1')
|
||||
.slice(dev ? 1 : 0)
|
||||
.replaceAll('%', '')
|
||||
.split(' ')
|
||||
.map(Number) as [number, number, number])
|
||||
)
|
||||
}
|
||||
|
||||
$: if (scrollY) {
|
||||
pin = lastY - scrollY > 0 || scrollY === 0 ? true : false
|
||||
lastY = scrollY
|
||||
if (browser)
|
||||
percent =
|
||||
Math.round((scrollY / (document.documentElement.scrollHeight - document.documentElement.clientHeight)) * 10000) / 100
|
||||
}
|
||||
|
||||
if (browser)
|
||||
currentTheme =
|
||||
localStorage.getItem('theme') ?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'night' : 'lemonade')
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="theme-color" content={currentThemeColor} />
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window bind:scrollY />
|
||||
|
||||
<header
|
||||
id="header"
|
||||
class:-translate-y-32={!pin && scrollY > 0}
|
||||
class="fixed z-50 w-screen transition-all duration-500 ease-in-out border-b-2 border-transparent max-h-[4.125rem] {scrollY >
|
||||
32 && 'backdrop-blur border-base-content/10 bg-base-100/30 md:bg-base-200/30'}">
|
||||
<div in:fly={{ x: -50, duration: 300, delay: 300 }} out:fly={{ x: -50, duration: 300 }} class="navbar">
|
||||
<div class="navbar-start">
|
||||
{#if headerConfig.nav}
|
||||
<Nav {path} {title} {pin} {scrollY} nav={headerConfig.nav} />
|
||||
{/if}
|
||||
<a href="/" sveltekit:prefetch class="btn btn-ghost normal-case text-lg">{site.title}</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<!-- {#if headerConfig.search} -->
|
||||
<!-- The button to open modal -->
|
||||
<!-- <label for="search-modal" class="btn btn-square btn-ghost ml-2"><span class="i-heroicons-outline-search" /></label> -->
|
||||
<!-- <button
|
||||
on:click={() => {
|
||||
search = !search
|
||||
}}
|
||||
type="submit"
|
||||
class="btn btn-square btn-ghost ml-2">
|
||||
<span class="i-heroicons-outline-search" />
|
||||
</button> -->
|
||||
<!-- {/if} -->
|
||||
<div id="change-theme" class="dropdown dropdown-end">
|
||||
<div tabindex="0" class="btn btn-square btn-ghost">
|
||||
<span class="i-heroicons-outline-color-swatch" />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="flex shadow-2xl menu dropdown-content bg-base-100 text-base-content rounded-box w-52 p-2 gap-2 overflow-y-auto max-h-[21.5rem]"
|
||||
class:hidden={!pin}>
|
||||
{#each theme as { name, text }}
|
||||
<button
|
||||
data-theme={name}
|
||||
on:click={() => {
|
||||
currentTheme = name
|
||||
localStorage.setItem('theme', name)
|
||||
}}
|
||||
class:border-2={currentTheme === name}
|
||||
class:border-primary={currentTheme === name}
|
||||
class="btn btn-ghost hover:bg-primary group rounded-lg flex bg-base-100 p-2 transition-all">
|
||||
<p class="flex-1 text-left text-base-content group-hover:text-primary-content transition-color">
|
||||
{text ?? name}
|
||||
</p>
|
||||
<div class="flex-none m-auto flex gap-1">
|
||||
<div class="bg-primary w-2 h-4 rounded" />
|
||||
<div class="bg-secondary w-2 h-4 rounded" />
|
||||
<div class="bg-accent w-2 h-4 rounded" />
|
||||
<div class="bg-neutral w-2 h-4 rounded" />
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
id="totop"
|
||||
on:click={() => window.scrollTo(0, 0)}
|
||||
class:translate-y-24={!pin || scrollY === 0}
|
||||
aria-label="scroll to top"
|
||||
class="fixed grid group btn btn-circle btn-lg border-none backdrop-blur bottom-6 right-6 z-50 duration-500 ease-in-out {percent >
|
||||
95
|
||||
? 'btn-accent shadow-lg'
|
||||
: 'btn-ghost bg-base-100/30 md:bg-base-200/30'}"
|
||||
class:opacity-100={scrollY}>
|
||||
<div
|
||||
class="radial-progress text-accent transition-all duration-500 ease-in-out group-hover:text-accent-focus col-start-1 row-start-1"
|
||||
style={`--size:4rem; --thickness: 0.25rem; --value:${percent};`} />
|
||||
<div
|
||||
class:border-transparent={percent > 95}
|
||||
class="border-4 border-base-content/10 group-hover:border-transparent col-start-1 row-start-1 rounded-full w-full h-full p-4 grid duration-500 ease-in-out">
|
||||
<span class="i-heroicons-solid-chevron-up !w-6 !h-6" />
|
||||
</div>
|
||||
</button>
|
71
src/lib/components/header_nav.svelte
Normal file
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
export let nav: { text: string; link?: string; children?: { text: string; link: string }[] }[]
|
||||
export let path: string
|
||||
export let title: string
|
||||
export let scrollY: number
|
||||
export let pin: boolean
|
||||
</script>
|
||||
|
||||
<div class="dropdown lg:hidden">
|
||||
<label for="navbar-dropdown" tabindex="0" class="btn btn-square btn-ghost">
|
||||
<span class="i-heroicons-outline-menu-alt-1" />
|
||||
</label>
|
||||
<ul
|
||||
id="navbar-dropdown"
|
||||
tabindex="0"
|
||||
class:hidden={!pin}
|
||||
class="menu menu-compact dropdown-content bg-base-100 text-base-content shadow-lg rounded-box max-w-52 p-2">
|
||||
{#each nav as { text, link, children }}
|
||||
{#if link && !children}
|
||||
<li>
|
||||
<a sveltekit:prefetch class:font-bold={link === path} href={link}>{text}</a>
|
||||
</li>
|
||||
{:else if children}
|
||||
<li tabindex="0">
|
||||
<span class:font-bold={children.some(({ link }) => link === path)} class="justify-between gap-1 max-w-[13rem]">
|
||||
{text}
|
||||
<span class="i-heroicons-solid-chevron-right mr-2" />
|
||||
</span>
|
||||
<ul class="bg-base-100 text-base-content shadow-lg p-2">
|
||||
{#each children as { text, link }}
|
||||
<li>
|
||||
<a sveltekit:prefetch class:font-bold={link === path} href={link}>{text}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<div class:swap-active={scrollY > 32 && title} class="swap order-last hidden lg:inline-grid">
|
||||
<button
|
||||
on:click={() => window.scrollTo(0, 0)}
|
||||
class:hidden={scrollY < 32 || !title}
|
||||
class="swap-on btn btn-ghost text-base font-normal normal-case transition-all duration-200">
|
||||
{title}
|
||||
</button>
|
||||
<ul class:hidden={scrollY > 64 && title} class="swap-off menu menu-horizontal p-0">
|
||||
{#each nav as { text, link, children }}
|
||||
{#if link && !children}
|
||||
<li>
|
||||
<a sveltekit:prefetch class:font-bold={link === path} href={link}>{text}</a>
|
||||
</li>
|
||||
{:else if children}
|
||||
<li tabindex="0">
|
||||
<span class:font-bold={children.some(({ link }) => link === path)} class="gap-1">
|
||||
{text}
|
||||
<span class="i-heroicons-solid-chevron-down -mr-1" />
|
||||
</span>
|
||||
<ul class="bg-base-100 text-base-content shadow-lg p-2">
|
||||
{#each children as { text, link }}
|
||||
<li>
|
||||
<a sveltekit:prefetch class:font-bold={link === path} href={link}>{text}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
102
src/lib/components/header_search.svelte
Normal file
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { site } from '$lib/config/site'
|
||||
import { header as headerConfig } from '$lib/config/general'
|
||||
import Typeahead from 'svelte-typeahead'
|
||||
import { page } from '$app/stores'
|
||||
// ref : https://github.com/saadeghi/daisyui/blob/master/src/docs/src/components/Search.svelte
|
||||
</script>
|
||||
|
||||
<form>
|
||||
<input type="checkbox" id="search-modal" class="modal-toggle " />
|
||||
<label for="search-modal" class="modal cursor-pointer w-full items-start pt-16 ">
|
||||
<label class="modal-box w-11/12 max-w-4xl " for="">
|
||||
<div class="form-control">
|
||||
<span
|
||||
class="i-heroicons-outline-search text-base-content pointer-events-none absolute z-10 my-2 ml-3 stroke-current opacity-60 w-5" />
|
||||
</div>
|
||||
<Typeahead placeholder="Search is not avaible …" limit={8} label="Search" inputAfterSelect="clear">
|
||||
<div class="py-1 text-sm" />
|
||||
</Typeahead>
|
||||
<div class="pointer-events-none absolute right-14 top-8 gap-1 opacity-50 hidden lg:flex">
|
||||
<kbd class="kbd kbd-sm">ESC</kbd>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<!-- <script lang="ts">
|
||||
import { onMount, createEventDispatcher } from "svelte"
|
||||
import { page } from "$app/stores"
|
||||
import { goto } from "$app/navigation"
|
||||
import Typeahead from "svelte-typeahead"
|
||||
import { pages } from "@src/lib/data.js"
|
||||
const dispatch = createEventDispatcher()
|
||||
let searchIndex = []
|
||||
pages.forEach((group) => {
|
||||
group.items.forEach((item) => {
|
||||
searchIndex.push(item)
|
||||
})
|
||||
})
|
||||
|
||||
let seachboxEl
|
||||
function handleKeydown(e) {
|
||||
if ((e.keyCode === 75 && e.metaKey) || (e.keyCode === 75 && e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
seachboxEl.querySelector("input[type=search]").focus()
|
||||
dispatch("focus")
|
||||
}
|
||||
}
|
||||
function onSelect({ detail }) {
|
||||
goto(searchIndex[detail.originalIndex].href)
|
||||
dispatch("search", detail)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
-->
|
||||
<style global>
|
||||
.searchbox [data-svelte-typeahead] {
|
||||
background: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
[data-svelte-search] label {
|
||||
display: none;
|
||||
}
|
||||
[data-svelte-search] input {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
border-radius: 0.5em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
[data-svelte-search] input::placeholder {
|
||||
color: inherit;
|
||||
}
|
||||
[data-svelte-search] input:focus {
|
||||
outline-color: hsla(var(--bc) / 0.2);
|
||||
background-color: hsl(var(--b1));
|
||||
color: hsla(var(--bc));
|
||||
}
|
||||
[data-svelte-typeahead] .svelte-typeahead-list {
|
||||
transform: translateY(0.5em);
|
||||
background: hsl(var(--b1));
|
||||
border-radius: var(--rounded-btn);
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-svelte-typeahead] .svelte-typeahead-list .selected,
|
||||
[data-svelte-typeahead] .svelte-typeahead-list .selected:hover {
|
||||
background: hsl(var(--n));
|
||||
color: hsl(var(--nc));
|
||||
}
|
||||
[data-svelte-typeahead] .svelte-typeahead-list li {
|
||||
color: hsl(var(--bc));
|
||||
}
|
||||
[data-svelte-typeahead] .svelte-typeahead-list li:hover {
|
||||
background: hsl(var(--b2));
|
||||
color: hsl(var(--bc));
|
||||
}
|
||||
[data-svelte-typeahead] .svelte-typeahead-list li:not(:last-of-type) {
|
||||
border-bottom-color: hsla(var(--bc) / 0.1);
|
||||
}
|
||||
</style>
|
174
src/lib/components/index_profile.svelte
Normal file
|
@ -0,0 +1,174 @@
|
|||
<script lang="ts">
|
||||
import { site } from '$lib/config/site'
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-card flex flex-col gap-4 sticky top-24 card card-body p-4 items-right xl:border-2 xl:py-8 border-base-content/10 xl:ml-auto xl:mr-8 xl:max-w-xs">
|
||||
<a href={site.protocol + site.domain} class="hidden u-url u-uid">{site.author.name}</a>
|
||||
<figure class="relative mx-auto group">
|
||||
<img
|
||||
class="rounded-full shadow-xl w-32 h-32 hover:rotate-[360deg] transition-transform duration-1000 ease-in-out"
|
||||
src={site.author.avatar}
|
||||
alt={site.author.name} />
|
||||
{#if site.author.status}
|
||||
<div class="heart absolute rounded-full w-10 h-10 bottom-0 right-0 bg-base-100 shadow-xl text-xl text-center py-1.5">
|
||||
{site.author.status}
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
<div class="text-center flex flex-col gap-2">
|
||||
<h2 class="text-2xl font-bold mt-0 mb-2 p-name">{site.author.name}</h2>
|
||||
<p class="opacity-75 p-note">{@html site.author.bio}</p>
|
||||
<label for="remote-follow" class="btn btn-active btn-outline btn-xs modal-button w-fit mx-auto normal-case mt-4 gap-2 ">
|
||||
<span class="i-material-symbols-group-add-rounded" />
|
||||
Remote Follow
|
||||
</label>
|
||||
{#if site.author.metadata}
|
||||
<div class="flex gap-1 flex-wrap justify-center">
|
||||
{#each site.author.metadata as { text, icon, link, rel }}
|
||||
{#if link}
|
||||
<a
|
||||
href={link}
|
||||
rel={rel ?? 'me noopener external'}
|
||||
class:btn-square={!text}
|
||||
class="btn btn-sm btn-ghost normal-case gap-2 u-url"
|
||||
target="_blank">
|
||||
{#if icon}
|
||||
<span class="{icon} !w-5 !h-5" />
|
||||
{/if}
|
||||
{#if text}
|
||||
{text}
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<button class:btn-square={!text} class="btn btn-sm btn-ghost normal-case gap-2" {rel}>
|
||||
{#if icon}
|
||||
<span class="{icon} !w-5 !h-5" />
|
||||
{/if}
|
||||
{#if text}
|
||||
{text}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.heart {
|
||||
-webkit-animation: heartbeat 2s linear 1s infinite;
|
||||
-o-animation: heartbeat 2s linear 1s infinite;
|
||||
-moz-animation: heartbeat 2s linear 1s infinite;
|
||||
-ms-animation: heartbeat 2s linear 1s infinite;
|
||||
animation: heartbeat 2s linear 1s infinite;
|
||||
}
|
||||
@keyframes heartbeat {
|
||||
0% {
|
||||
-webkit-transform: scale(1);
|
||||
-o-transform: scale(1);
|
||||
-moz-transform: scale(1);
|
||||
-ms-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
2% {
|
||||
-webkit-transform: scale(1);
|
||||
-o-transform: scale(1);
|
||||
-moz-transform: scale(1);
|
||||
-ms-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
4% {
|
||||
-webkit-transform: scale(1.08);
|
||||
-o-transform: scale(1.08);
|
||||
-moz-transform: scale(1.08);
|
||||
-ms-transform: scale(1.08);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
8% {
|
||||
-webkit-transform: scale(1.1);
|
||||
-o-transform: scale(1.1);
|
||||
-moz-transform: scale(1.1);
|
||||
-ms-transform: scale(1.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
20% {
|
||||
-webkit-transform: scale(0.96);
|
||||
-o-transform: scale(0.96);
|
||||
-moz-transform: scale(0.96);
|
||||
-ms-transform: scale(0.96);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
24% {
|
||||
-webkit-transform: scale(1.1);
|
||||
-o-transform: scale(1.1);
|
||||
-moz-transform: scale(1.1);
|
||||
-ms-transform: scale(1.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
32% {
|
||||
-webkit-transform: scale(1.08);
|
||||
-o-transform: scale(1.08);
|
||||
-moz-transform: scale(1.08);
|
||||
-ms-transform: scale(1.08);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
-o-transform: scale(1);
|
||||
-moz-transform: scale(1);
|
||||
-ms-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes heartbeat {
|
||||
0% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
2% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
4% {
|
||||
-webkit-transform: scale(1.08);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
8% {
|
||||
-webkit-transform: scale(1.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
20% {
|
||||
-webkit-transform: scale(0.96);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
24% {
|
||||
-webkit-transform: scale(1.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
32% {
|
||||
-webkit-transform: scale(1.08);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
40% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
12
src/lib/components/post_action.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
export let post: Urara.Post
|
||||
const actions = import.meta.glob<{ default: unknown }>('/src/lib/components/actions/*.svelte', { eager: true })
|
||||
</script>
|
||||
|
||||
<div class="sticky top-24 hidden xl:flex flex-col gap-4 w-fit h-[calc(100vh-12rem)] ml-auto mr-8 my-8 justify-center">
|
||||
{#if Object.keys(actions).length}
|
||||
{#each Object.values(actions) as action}
|
||||
<svelte:component this={action.default} {post} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
129
src/lib/components/post_card.svelte
Normal file
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/env'
|
||||
import { post as postConfig } from '$lib/config/post'
|
||||
import { posts as storedPosts } from '$lib/stores/posts'
|
||||
import { title as storedTitle } from '$lib/stores/title'
|
||||
import Reply from '$lib/components/post_reply.svelte'
|
||||
import Status from '$lib/components/post_status.svelte'
|
||||
import Image from '$lib/components/prose/img.svelte'
|
||||
import Pagination from '$lib/components/post_pagination.svelte'
|
||||
import Comment from '$lib/components/post_comment.svelte'
|
||||
export let post: Urara.Post
|
||||
export let preview: boolean = false
|
||||
export let loading: 'eager' | 'lazy' = 'lazy'
|
||||
export let decoding: 'async' | 'sync' | 'auto' = 'async'
|
||||
// pagination
|
||||
let index: number
|
||||
let prev: Urara.Post | undefined = undefined
|
||||
let next: Urara.Post | undefined = undefined
|
||||
if (browser && !preview)
|
||||
storedPosts.subscribe((storedPosts: Urara.Post[]) => {
|
||||
index = storedPosts.findIndex(storedPost => storedPost.path === post.path)
|
||||
prev = storedPosts
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find(post => !post.flags?.includes('unlisted'))
|
||||
next = storedPosts.slice(index + 1).find(post => !post.flags?.includes('unlisted'))
|
||||
storedTitle.set(post.title ?? post.path.slice(1))
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={post.type === 'article' ? 'article' : 'div'}
|
||||
itemscope
|
||||
itemtype="https://schema.org/BlogPosting"
|
||||
itemprop="blogPost"
|
||||
class:md:mb-8={!preview}
|
||||
class:lg:mb-16={!preview}
|
||||
class:h-entry={preview}
|
||||
class:group={preview}
|
||||
class:image-full={preview && post.type === 'article' && post.image}
|
||||
class:before:!rounded-none={preview && post.image}
|
||||
class="card bg-base-100 rounded-none md:rounded-box md:shadow-xl z-10">
|
||||
{#if !preview && postConfig.bridgy}
|
||||
<div id="bridgy" class="hidden">
|
||||
{#each post.flags?.some( flag => flag.startsWith('bridgy') ) ? post.flags.flatMap( flag => (flag.startsWith('bridgy') ? flag.slice(7) : []) ) : [...(postConfig.bridgy.post ?? []), ...(postConfig.bridgy[post.type] ?? [])] as target}
|
||||
{#if target === 'fed'}
|
||||
<a href="https://fed.brid.gy/">fed</a>
|
||||
{:else}
|
||||
<a href="https://brid.gy/publish/{target}">{target}</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if post.in_reply_to}
|
||||
<Reply in_reply_to={post.in_reply_to} class="mt-4 mx-4" />
|
||||
{/if}
|
||||
{#if post.image && preview}
|
||||
<figure class="!block">
|
||||
<Image
|
||||
class={post.type === 'article'
|
||||
? 'u-featured object-center h-full w-full absolute group-hover:scale-105 transition-transform duration-500 ease-in-out'
|
||||
: 'u-photo rounded-xl md:rounded-b-none -mb-6 md:-mb-2'}
|
||||
src={post.image}
|
||||
alt={post.image}
|
||||
{loading}
|
||||
{decoding} />
|
||||
</figure>
|
||||
{/if}
|
||||
<div
|
||||
class={`card-body gap-0 ${
|
||||
preview && post.type === 'article' && post.image ? 'md:col-start-1 md:row-start-1 md:text-neutral-content md:z-20' : ''
|
||||
}`}>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#if post.image && !preview}
|
||||
<figure
|
||||
class={`md:order-last mb-4 ${post.type === 'article' ? 'flex-col gap-2 -mx-4 -mt-8 md:mt-0' : 'flex-col -mx-8'}`}>
|
||||
<Image
|
||||
class={`${post.type === 'article' ? 'u-featured rounded-box shadow-xl' : 'u-photo'}`}
|
||||
src={post.image}
|
||||
alt={post.image}
|
||||
{loading}
|
||||
{decoding} />
|
||||
</figure>
|
||||
{/if}
|
||||
<Status {post} {preview} />
|
||||
{#if post.title}
|
||||
{#if preview}
|
||||
<h2
|
||||
itemprop="name headline"
|
||||
class="card-title text-2xl mr-auto bg-[length:100%_0%] bg-[position:0_88%] underline decoration-4 decoration-transparent group-hover:decoration-primary hover:bg-[length:100%_100%] hover:text-primary-content bg-gradient-to-t from-primary to-primary bg-no-repeat transition-all ease-in-out duration-300">
|
||||
<a itemprop="url" class="u-url p-name" href={post.path}>{post.title ?? post.path.slice(1)}</a>
|
||||
</h2>
|
||||
{:else}
|
||||
<h1 itemprop="name headline" class="card-title text-3xl mb-8 p-name">{post.title ?? post.path.slice(1)}</h1>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if post.summary}
|
||||
<p itemprop="description" class:hidden={!preview || post.type !== 'article'} class="p-summary mb-auto">
|
||||
{post.summary}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<main itemprop="articleBody" class:mt-4={post.type !== 'article'} class="urara-prose prose e-content">
|
||||
{#if !preview}
|
||||
<slot />
|
||||
{:else if post.html}
|
||||
{@html post.html}
|
||||
{/if}
|
||||
</main>
|
||||
{#if !preview && post.tags}
|
||||
<div class="divider mt-4 mb-0" />
|
||||
<div>
|
||||
{#each post.tags as tag}
|
||||
<a href="/?tags={tag}" class="btn btn-sm btn-ghost normal-case mt-2 mr-2 p-category">
|
||||
#{tag}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !preview}
|
||||
{#if (prev || next) && !post.flags?.includes('pagination-disabled') && !post.flags?.includes('unlisted')}
|
||||
<Pagination {next} {prev} />
|
||||
{/if}
|
||||
{#if browser && postConfig.comment && !post.flags?.includes('comment-disabled')}
|
||||
<Comment {post} config={postConfig.comment} />
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:element>
|
42
src/lib/components/post_comment.svelte
Normal file
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import type { CommentConfig } from '$lib/types/post'
|
||||
import { toSnake } from '$lib/utils/case'
|
||||
export let post: Urara.Post
|
||||
export let config: CommentConfig
|
||||
const comments = import.meta.glob<{ default: unknown }>('/src/lib/components/comments/*.svelte', { eager: true })
|
||||
let currentComment: string | undefined = undefined
|
||||
let currentConfig: unknown | undefined = undefined
|
||||
currentComment = localStorage.getItem('comment') ?? toSnake(config.use[0])
|
||||
// @ts-ignore No index signature with a parameter of type 'string' was found on type 'CommentConfig'. ts(7053)
|
||||
$: if (currentComment) currentConfig = config[currentComment]
|
||||
</script>
|
||||
|
||||
{#if config?.use.length > 0}
|
||||
<div id="post-comment" class="card card-body">
|
||||
{#if config.use.length > 1}
|
||||
<div class="tabs w-full mb-8" class:tabs-boxed={config?.['style'] === 'boxed'}>
|
||||
{#each config.use as name}
|
||||
<span
|
||||
on:click={() => {
|
||||
currentComment = toSnake(name)
|
||||
localStorage.setItem('comment', toSnake(name))
|
||||
}}
|
||||
class="flex-1 tab transition-all"
|
||||
class:tab-bordered={config?.['style'] === 'bordered'}
|
||||
class:tab-lifted={config?.['style'] === 'lifted'}
|
||||
class:tab-active={currentComment === toSnake(name)}>
|
||||
{name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if currentComment}
|
||||
{#key currentComment}
|
||||
<svelte:component
|
||||
this={comments[`/src/lib/components/comments/${currentComment}.svelte`].default}
|
||||
{post}
|
||||
config={currentConfig} />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
46
src/lib/components/post_container.svelte
Normal file
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition'
|
||||
import { browser } from '$app/env'
|
||||
import Card from '$lib/components/post_card.svelte'
|
||||
import Head from '$lib/components/head.svelte'
|
||||
import Toc from '$lib/components/post_toc.svelte'
|
||||
import Action from '$lib/components/post_action.svelte'
|
||||
import Footer from '$lib/components/footer.svelte'
|
||||
export let post: Urara.Post
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
import { copyCode } from '$lib/utils/copyCode'
|
||||
|
||||
onMount(() => {
|
||||
copyCode()
|
||||
})
|
||||
</script>
|
||||
|
||||
<Head {post} />
|
||||
|
||||
<div class="flex flex-col flex-nowrap justify-center xl:flex-row xl:flex-wrap">
|
||||
<div
|
||||
in:fly={{ x: 25, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: 25, duration: 300 }}
|
||||
class="flex-1 w-full max-w-screen-md order-first ease-out transform mx-auto xl:mr-0">
|
||||
{#if browser}
|
||||
<Action {post} />
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
in:fly={{ x: -25, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: -25, duration: 300 }}
|
||||
class="flex-1 w-full max-w-screen-md xl:order-last ease-out transform mx-auto xl:mr-0">
|
||||
{#if browser && post.toc}
|
||||
<div class="h-full hidden xl:block">
|
||||
<Toc toc={post.toc} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-none w-full max-w-screen-md mx-auto xl:mx-0">
|
||||
<Card {post}>
|
||||
<slot />
|
||||
</Card>
|
||||
<Footer sticky={true} />
|
||||
</div>
|
||||
</div>
|
32
src/lib/components/post_layout.svelte
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts" context="module">
|
||||
import Image from '$lib/components/prose/img.svelte'
|
||||
import table from '$lib/components/prose/table.svelte'
|
||||
export { Image as img, table }
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { typeOfPost } from '$lib/utils/posts'
|
||||
import Container from '$lib/components/post_container.svelte'
|
||||
// auto-generated
|
||||
export let path
|
||||
export let slug
|
||||
export let toc
|
||||
// common
|
||||
export let created
|
||||
export let updated
|
||||
export let published
|
||||
export let summary
|
||||
export let tags
|
||||
export let flags
|
||||
// specify
|
||||
export let title
|
||||
export let image
|
||||
export let in_reply_to
|
||||
// post
|
||||
let fm = { path, slug, toc, created, updated, published, summary, tags, flags, title, image, in_reply_to }
|
||||
let post = { type: typeOfPost(fm), ...fm }
|
||||
</script>
|
||||
|
||||
<Container {post}>
|
||||
<slot />
|
||||
</Container>
|
59
src/lib/components/post_pagination.svelte
Normal file
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
import Image from '$lib/components/prose/img.svelte'
|
||||
export let prev: Urara.Post | undefined = undefined
|
||||
export let next: Urara.Post | undefined = undefined
|
||||
</script>
|
||||
|
||||
<nav class="flex flex-col md:flex-row flex-warp justify-evenly">
|
||||
{#if prev}
|
||||
<div
|
||||
href={prev.path}
|
||||
class:image-full={prev['image']}
|
||||
class:md:rounded-r-box={next && !next['image']}
|
||||
class="flex-1 card group rounded-none before:!rounded-none">
|
||||
{#if prev['image']}
|
||||
<figure class="!block">
|
||||
<Image
|
||||
class="object-center h-full w-full absolute group-hover:scale-105 transition-transform duration-500 ease-in-out"
|
||||
src={prev['image']} />
|
||||
</figure>
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
<span class="i-heroicons-outline-chevron-left opacity-50 group-hover:opacity-100 mr-auto" />
|
||||
<a
|
||||
rel="prev"
|
||||
href={prev.path}
|
||||
class="card-title block text-left mb-0 mr-auto bg-[length:100%_0%] bg-[position:0_88%] underline decoration-3 decoration-transparent group-hover:decoration-primary hover:bg-[length:100%_100%] hover:text-primary-content bg-gradient-to-t from-primary to-primary bg-no-repeat transition-all ease-in-out duration-300">
|
||||
{prev['title'] ?? prev['summary'] ?? prev.path.slice(1)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{#if next && !next['image'] && !prev['image']}
|
||||
<div class="flex-0 divider mx-4 md:divider-horizontal md:mx-0 md:my-4" />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if next}
|
||||
<div
|
||||
href={next.path}
|
||||
class:image-full={next['image']}
|
||||
class:md:rounded-l-box={prev && !prev['image']}
|
||||
class="flex-1 card group rounded-none before:!rounded-none">
|
||||
{#if next['image']}
|
||||
<figure class="!block">
|
||||
<Image
|
||||
class="object-center h-full w-full absolute group-hover:scale-105 transition-transform duration-500 ease-in-out"
|
||||
src={next['image']} />
|
||||
</figure>
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
<a
|
||||
rel="next"
|
||||
href={next.path}
|
||||
class="card-title block text-right mb-0 ml-auto bg-[length:100%_0%] bg-[position:0_88%] underline decoration-3 decoration-transparent group-hover:decoration-primary hover:bg-[length:100%_100%] hover:text-primary-content bg-gradient-to-t from-primary to-primary bg-no-repeat transition-all ease-in-out duration-300">
|
||||
{next['title'] ?? next['summary'] ?? next.path.slice(1)}
|
||||
</a>
|
||||
<span class="i-heroicons-outline-chevron-right opacity-50 group-hover:opacity-100 ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
30
src/lib/components/post_reply.svelte
Normal file
|
@ -0,0 +1,30 @@
|
|||
<script lang="ts">
|
||||
let className = ''
|
||||
export { className as class }
|
||||
export let in_reply_to: Urara.Post['in_reply_to']
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-2 rounded-box outline outline-neutral/10 p-4 {className}">
|
||||
<span class="flex-none font-bold uppercase opacity-30">Reply to: </span>
|
||||
{#if Array.isArray(in_reply_to)}
|
||||
{#each in_reply_to as reply}
|
||||
<a
|
||||
href={reply}
|
||||
rel="noopener external"
|
||||
target="_blank"
|
||||
class="flex-none flex rounded-badge bg-base-200 hover:bg-base-300 transition-all gap-2 px-4 u-in-reply-to">
|
||||
<span class="i-heroicons-outline-reply my-auto !w-4 !h-4" />
|
||||
{reply}
|
||||
</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<a
|
||||
href={in_reply_to}
|
||||
rel="noopener external"
|
||||
target="_blank"
|
||||
class="ml-auto flex-none flex rounded-badge bg-base-200 hover:bg-base-300 transition-all gap-2 px-4 u-in-reply-to">
|
||||
<span class="i-heroicons-outline-reply my-auto !w-4 !h-4" />
|
||||
{in_reply_to}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
35
src/lib/components/post_status.svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { date } from '$lib/config/general'
|
||||
import { site } from '$lib/config/site'
|
||||
export let post: Urara.Post
|
||||
export let preview: boolean = false
|
||||
const stringPublished = new Date(post.published ?? post.created).toLocaleString(date.locales, date.options)
|
||||
const stringUpdated = new Date(post.updated ?? post.published ?? post.created).toLocaleString(date.locales, date.options)
|
||||
const jsonPublished = new Date(post.published ?? post.created).toJSON()
|
||||
const jsonUpdated = new Date(post.updated ?? post.published ?? post.created).toJSON()
|
||||
</script>
|
||||
|
||||
<div class:md:mb-4={!preview && post.type !== 'article'} class="flex font-semibold gap-1.5">
|
||||
<a
|
||||
class:hidden={preview}
|
||||
rel="author"
|
||||
class="opacity-75 hover:opacity-100 hover:text-primary duration-500 ease-in-out p-author h-card"
|
||||
href={site.protocol + site.domain}>
|
||||
{site.author.name}
|
||||
</a>
|
||||
<span class:hidden={preview} class="opacity-50">/</span>
|
||||
<a href={post.path} class="swap hover:swap-active u-url u-uid">
|
||||
<time
|
||||
class="swap-off font-semibold opacity-75 duration-500 ease-in-out mr-auto dt-published"
|
||||
datetime={jsonPublished}
|
||||
itemprop="datePublished">
|
||||
{stringPublished}
|
||||
</time>
|
||||
<time
|
||||
class="swap-on font-semibold text-primary duration-500 ease-in-out mr-auto dt-updated"
|
||||
datetime={jsonUpdated}
|
||||
itemprop="dateModified">
|
||||
{stringUpdated}
|
||||
</time>
|
||||
</a>
|
||||
</div>
|
68
src/lib/components/post_toc.svelte
Normal file
|
@ -0,0 +1,68 @@
|
|||
<script lang="ts" context="module">
|
||||
export const prerender = true
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import Tree from '$lib/components/post_toc_tree.svelte'
|
||||
export let toc: Urara.Post.Toc[]
|
||||
|
||||
let intersecting: string[] = []
|
||||
let intersectingArticle: boolean = true
|
||||
let bordered: string[] = []
|
||||
|
||||
onMount(() => {
|
||||
if (window.screen.availWidth >= 1280) {
|
||||
const headingsObserver = new IntersectionObserver(
|
||||
headings =>
|
||||
headings.forEach(heading =>
|
||||
heading.isIntersecting
|
||||
? intersecting.push(heading.target.id)
|
||||
: (intersecting = intersecting.filter(h => h !== heading.target.id))
|
||||
),
|
||||
{ rootMargin: '-64px 0px 0px 0px' }
|
||||
)
|
||||
const articleObserver = new IntersectionObserver(article => (intersectingArticle = article[0].isIntersecting))
|
||||
Array.from(document.querySelectorAll('main h2, main h3, main h4, main h5, main h6')).forEach(element =>
|
||||
headingsObserver.observe(element)
|
||||
)
|
||||
articleObserver.observe(document.getElementsByTagName('main')[0])
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
// @ts-ignore: Cannot find name 'headingsObserver'
|
||||
if (typeof headingsObserver !== 'undefined') headingsObserver.disconnect()
|
||||
// @ts-ignore: Cannot find name 'articleObserver'
|
||||
if (typeof headingsObserver !== 'undefined') articleObserver.disconnect()
|
||||
})
|
||||
|
||||
$: if (intersecting.length > 0) bordered = intersecting
|
||||
$: if (intersectingArticle === false) bordered = []
|
||||
$: if (bordered)
|
||||
toc.forEach(heading =>
|
||||
bordered.includes(heading.slug!)
|
||||
? document.getElementById(`toc-link-${heading.slug}`)?.classList.add('!border-accent')
|
||||
: document.getElementById(`toc-link-${heading.slug}`)?.classList.remove('!border-accent')
|
||||
)
|
||||
</script>
|
||||
|
||||
<aside class="sticky top-16 py-8">
|
||||
<nav
|
||||
id="post-toc"
|
||||
aria-label="TableOfContent"
|
||||
dir="rtl"
|
||||
class="max-h-[calc(100vh-12rem)] overflow-y-hidden hover:overflow-y-auto">
|
||||
<Tree
|
||||
toc={toc.reduce(
|
||||
(acc, heading) => {
|
||||
let parent = acc
|
||||
// @ts-ignore Type 'Toc | undefined' is not assignable to type 'Toc.' ts(2322)
|
||||
while (parent.depth + 1 < heading.depth) parent = parent.children.at(-1)
|
||||
parent.children = [...(parent.children ?? []), { ...heading, children: [] }]
|
||||
return acc
|
||||
},
|
||||
{ depth: toc[0].depth - 1, children: [] }
|
||||
)} />
|
||||
</nav>
|
||||
</aside>
|
33
src/lib/components/post_toc_tree.svelte
Normal file
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
export let toc: Urara.Post.Toc
|
||||
const { title, slug, depth, children } = toc
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<span
|
||||
dir="ltr"
|
||||
on:click={() =>
|
||||
// @ts-ignore Object is possibly 'null'. ts(2531)
|
||||
document.getElementById(slug).scrollIntoView({ behavior: 'smooth' })}
|
||||
id={`toc-link-${slug}`}
|
||||
class="cursor-pointer border-l-4 border-transparent transition-all hover:border-primary hover:bg-base-content hover:bg-opacity-10 active:bg-primary active:text-primary-content active:font-bold pr-4 {depth <=
|
||||
2
|
||||
? 'py-3'
|
||||
: 'py-2'}"
|
||||
class:pl-4={depth <= 2}
|
||||
class:pl-8={depth === 3}
|
||||
class:pl-12={depth === 4}
|
||||
class:pl-16={depth === 5}
|
||||
class:pl-20={depth === 6}>
|
||||
{title}
|
||||
</span>
|
||||
{/if}
|
||||
{#if children}
|
||||
<ul dir="ltr" id={`toc-list-${slug ?? 'root'}`}>
|
||||
{#each children as child}
|
||||
<li id={`toc-item-${child.slug}`} class="flex flex-col">
|
||||
<svelte:self toc={child} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
10
src/lib/components/prose/img.svelte
Normal file
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
let className: string | undefined = undefined
|
||||
export { className as class }
|
||||
export let src: string
|
||||
export let alt: string = src
|
||||
export let loading: 'eager' | 'lazy' = 'lazy'
|
||||
export let decoding: 'async' | 'sync' | 'auto' = 'async'
|
||||
</script>
|
||||
|
||||
<img {src} {alt} class={className ?? 'rounded-lg my-2'} {loading} {decoding} />
|
5
src/lib/components/prose/table.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="overflow-x-auto mb-4">
|
||||
<table class="table w-full">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
90
src/lib/config/friends.ts
Normal file
|
@ -0,0 +1,90 @@
|
|||
export interface FriendOld {
|
||||
// hCard+XFN
|
||||
id: string // HTML id
|
||||
rel?: string // XFN, contact / acquaintance / friend
|
||||
link?: string // URL
|
||||
html?: string // HTML
|
||||
title?: string // 标题
|
||||
descr?: string // 描述
|
||||
avatar?: string // 头像
|
||||
name?: string // backwards compatibility
|
||||
}
|
||||
|
||||
export type Friend = {
|
||||
id: string // HTML id
|
||||
rel?: string // XHTML Friends Network
|
||||
link?: string // URL
|
||||
html?: string // Custom HTML
|
||||
|
||||
title?: string // 标题
|
||||
name?: string // 人名
|
||||
avatar?: string // 头像
|
||||
descr?: string // 描述
|
||||
class?: {
|
||||
avatar?: string // 头像类名
|
||||
img?: string // 图片类名
|
||||
}
|
||||
}
|
||||
|
||||
export const friends: Friend[] = [
|
||||
{
|
||||
id: 'mantyke',
|
||||
rel: 'friend',
|
||||
name: '塔塔',
|
||||
title: '小球飞鱼',
|
||||
link: 'https://mantyke.icu/',
|
||||
descr: '我们会一起遇见鲸鱼吗?',
|
||||
avatar: 'https://mantyke.icu/images/logo.png'
|
||||
},
|
||||
{
|
||||
id: 'middleofnowhere',
|
||||
rel: 'friend',
|
||||
name: '陆博学',
|
||||
title: 'Middle of No where',
|
||||
link: 'https://notes.midofnowhere.link/',
|
||||
descr: `Welcome to the middle of nowhere. That's right, absolute nowhere.`,
|
||||
avatar: 'https://github.com/yue2/picbed/blob/main/cutepic.png?raw=true'
|
||||
},
|
||||
{
|
||||
id: 'chestnut',
|
||||
rel: 'friend',
|
||||
name: '栗',
|
||||
title: '野生栗子🌰',
|
||||
link: 'https://blog.chestnut.monster/'
|
||||
},
|
||||
{
|
||||
id: 'nekolas',
|
||||
rel: 'friend',
|
||||
name: 'Nic Tian',
|
||||
title: `Nekolas's blog`,
|
||||
link: 'https://blog.nekolas.cafe/',
|
||||
descr: '欢迎加入锈栓抵抗军',
|
||||
avatar: 'https://blogpic-1308403500.cos.ap-shanghai.myqcloud.com/avatar/nic-avatar-tomato.png'
|
||||
},
|
||||
{
|
||||
id: 'summerblue',
|
||||
rel: 'friend',
|
||||
name: '夏诤',
|
||||
title: 'SummberBlue',
|
||||
link: 'https://summerblue.space/',
|
||||
descr: '早睡早起身体好'
|
||||
},
|
||||
{
|
||||
id: 'loikin',
|
||||
rel: 'friend',
|
||||
name: 'Loikin',
|
||||
title: '此生未命名',
|
||||
link: 'https://blog.loikein.one/',
|
||||
descr: '用爱和理性对抗荒谬',
|
||||
avatar: '/assets/loikin.png'
|
||||
},
|
||||
{
|
||||
id: 'sharktale',
|
||||
rel: 'friend',
|
||||
name: '鲨',
|
||||
title: '一只脆脆鲨',
|
||||
link: 'http://blog.sharktale.xyz/',
|
||||
descr: '遇见一只脆脆鲨',
|
||||
avatar: 'https://s2.loli.net/2022/03/30/xwOzn9G8TIqFPvR.jpg'
|
||||
}
|
||||
]
|
219
src/lib/config/general.ts
Normal file
|
@ -0,0 +1,219 @@
|
|||
import type { ThemeConfig, HeadConfig, HeaderConfig, FooterConfig, DateConfig, FeedConfig } from '$lib/types/general'
|
||||
|
||||
export const theme: ThemeConfig = [
|
||||
{
|
||||
name: 'lemonade',
|
||||
text: 'Light'
|
||||
},
|
||||
{
|
||||
name: 'night',
|
||||
text: 'Dark'
|
||||
},
|
||||
{
|
||||
name: 'cupcake',
|
||||
text: 'Cupcake'
|
||||
},
|
||||
{
|
||||
name: 'bumblebee',
|
||||
text: 'Bumblebee'
|
||||
},
|
||||
{
|
||||
name: 'emerald',
|
||||
text: 'Emerald'
|
||||
},
|
||||
{
|
||||
name: 'corporate',
|
||||
text: 'Corporate'
|
||||
},
|
||||
{
|
||||
name: 'valentine',
|
||||
text: 'Valentine'
|
||||
},
|
||||
{
|
||||
name: 'synthwave',
|
||||
text: 'Synthwave'
|
||||
},
|
||||
{
|
||||
name: 'retro',
|
||||
text: 'Retro'
|
||||
},
|
||||
{
|
||||
name: 'cyberpunk',
|
||||
text: 'Cyberpunk'
|
||||
},
|
||||
{
|
||||
name: 'halloween',
|
||||
text: 'Halloween'
|
||||
},
|
||||
{
|
||||
name: 'garden',
|
||||
text: 'Garden'
|
||||
},
|
||||
{
|
||||
name: 'forest',
|
||||
text: 'Forest'
|
||||
},
|
||||
{
|
||||
name: 'aqua',
|
||||
text: 'Aqua'
|
||||
},
|
||||
{
|
||||
name: 'lofi',
|
||||
text: 'Lo-Fi'
|
||||
},
|
||||
{
|
||||
name: 'pastel',
|
||||
text: 'Pastel'
|
||||
},
|
||||
{
|
||||
name: 'fantasy',
|
||||
text: 'Fantasy'
|
||||
},
|
||||
{
|
||||
name: 'wirefream',
|
||||
text: 'Wireframe'
|
||||
},
|
||||
{
|
||||
name: 'black',
|
||||
text: 'Black'
|
||||
},
|
||||
{
|
||||
name: 'luxury',
|
||||
text: 'Luxury'
|
||||
},
|
||||
{
|
||||
name: 'dracula',
|
||||
text: 'Dracula'
|
||||
},
|
||||
{
|
||||
name: 'cmyk',
|
||||
text: 'CMYK'
|
||||
},
|
||||
{
|
||||
name: 'autumn',
|
||||
text: 'Autumn'
|
||||
},
|
||||
{
|
||||
name: 'business',
|
||||
text: 'Business'
|
||||
},
|
||||
{
|
||||
name: 'acid',
|
||||
text: 'Acid'
|
||||
},
|
||||
// {
|
||||
// name: 'lemonade',
|
||||
// text: 'Lemonade'
|
||||
// },
|
||||
// {
|
||||
// name: 'night',
|
||||
// text: '🌃 Night'
|
||||
// },
|
||||
{
|
||||
name: 'coffee',
|
||||
text: 'Coffee'
|
||||
},
|
||||
{
|
||||
name: 'winter',
|
||||
text: 'Winter'
|
||||
}
|
||||
]
|
||||
|
||||
export const head: HeadConfig = {
|
||||
custom: ({ dev }) =>
|
||||
dev
|
||||
? []
|
||||
: [
|
||||
// IndieAuth
|
||||
'<link rel="authorization_endpoint" href="https://indieauth.com/auth">',
|
||||
'<link rel="token_endpoint" href="https://tokens.indieauth.com/token">',
|
||||
'<link rel="me" href="https://github.com/sevichecc" />',
|
||||
// Umami Analytics
|
||||
'<script data-cfasync="false" defer data-do-not-track="true" data-website-id="2403ea30-74ff-4ffa-8264-556b9f3b2897" src="https://hexoverc.vercel.app/umami.js"></script>',
|
||||
// splitbee
|
||||
'<script async data-cfasync="false" src="https://cdn.splitbee.io/sb.js"></script>',
|
||||
// Block Baiduspider
|
||||
'<meta name="baiduspider" content="noindex,noarchive">',
|
||||
// Microsub
|
||||
'<link rel="microsub" href="https://aperture.p3k.io/microsub/761">'
|
||||
],
|
||||
me: ['https://kongwoo.icu/@seviche']
|
||||
}
|
||||
|
||||
export const header: HeaderConfig = {
|
||||
search: {
|
||||
provider: 'duckduckgo'
|
||||
},
|
||||
nav: [
|
||||
{
|
||||
text: 'Projects',
|
||||
link: '/projects'
|
||||
},
|
||||
// {
|
||||
// text: 'Notes',
|
||||
// link: '/notes'
|
||||
// },
|
||||
{
|
||||
text: 'Friends',
|
||||
link: '/friends'
|
||||
},
|
||||
{
|
||||
text: 'About',
|
||||
link: '/about'
|
||||
}
|
||||
// ,
|
||||
// {
|
||||
// text: 'Notes',
|
||||
// link: '/notes'
|
||||
// }
|
||||
]
|
||||
}
|
||||
|
||||
export const footer: FooterConfig = {
|
||||
nav: [
|
||||
{
|
||||
text: 'Feed',
|
||||
link: '/atom.xml'
|
||||
},
|
||||
{
|
||||
text: 'Pravicy',
|
||||
link: '/privacy'
|
||||
}
|
||||
],
|
||||
html: '<a rel="license" href="http://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>'
|
||||
}
|
||||
|
||||
export const date: DateConfig = {
|
||||
// toPublishedString: {
|
||||
// locales: 'en-US',
|
||||
// options: {
|
||||
// year: 'numeric',
|
||||
// weekday: 'short',
|
||||
// month: 'short',
|
||||
// day: 'numeric',
|
||||
// timeZone: 'Asia/Shanghai'
|
||||
// }
|
||||
// },
|
||||
// toUpdatedString: {
|
||||
// locales: 'en-US',
|
||||
// options: {
|
||||
// year: 'numeric',
|
||||
// weekday: 'short',
|
||||
// month: 'short',
|
||||
// day: 'numeric',
|
||||
// timeZone: 'Asia/Shanghai'
|
||||
// }
|
||||
// },
|
||||
locales: 'en-US',
|
||||
options: {
|
||||
year: 'numeric',
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'Asia/Shanghai'
|
||||
}
|
||||
}
|
||||
|
||||
export const feed: FeedConfig = {
|
||||
hubs: ['https://pubsubhubbub.appspot.com', 'https://bridgy-fed.superfeedr.com']
|
||||
}
|
39
src/lib/config/icon.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import type { Icon } from '$lib/types/icon'
|
||||
import { site } from '$lib/config/site'
|
||||
|
||||
export const favicon: Icon = {
|
||||
src: site.protocol + site.domain + '/favicon.png',
|
||||
sizes: '48x48',
|
||||
type: 'image/png'
|
||||
}
|
||||
|
||||
export const any: { [key: number]: Icon } = {
|
||||
180: {
|
||||
src: site.protocol + site.domain + '/assets/any@180.png',
|
||||
sizes: '180x180',
|
||||
type: 'image/png'
|
||||
},
|
||||
192: {
|
||||
src: site.protocol + site.domain + '/assets/any@192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
512: {
|
||||
src: site.protocol + site.domain + '/assets/any@512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
export const maskable: { [key: number]: Icon } = {
|
||||
192: {
|
||||
src: site.protocol + site.domain + '/assets/maskable@192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png'
|
||||
},
|
||||
512: {
|
||||
src: site.protocol + site.domain + '/assets/maskable@512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png'
|
||||
}
|
||||
}
|
37
src/lib/config/post.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import type { PostConfig } from '$lib/types/post'
|
||||
|
||||
export const post: PostConfig = {
|
||||
bridgy: {
|
||||
post: ['mastodon']
|
||||
},
|
||||
comment: {
|
||||
use: ['Webmention', 'Giscus'],
|
||||
style: 'boxed',
|
||||
webmention: {
|
||||
username: 'seviche.cc',
|
||||
sortBy: 'created',
|
||||
sortDir: 'down',
|
||||
form: true,
|
||||
commentParade: true
|
||||
},
|
||||
giscus: {
|
||||
// src: 'https://giscus.kwaa.dev/client.js',
|
||||
repo: 'Sevichecc/urara-giscus',
|
||||
repoID: 'R_kgDOHSra4Q',
|
||||
category: 'General',
|
||||
categoryID: 'DIC_kwDOHSra4c4CO9ua',
|
||||
theme: 'light',
|
||||
lang: 'en'
|
||||
}
|
||||
// waline: {
|
||||
// serverURL: 'https://waline-seviche.vercel.app/',
|
||||
// lang: 'en',
|
||||
// emoji: [
|
||||
// 'https://cdn.jsdelivr.net/gh/norevi/waline-blobcatemojis@1.0/blobs',
|
||||
// 'https://cdn.jsdelivr.net/gh/norevi/blob-emoji-for-waline@2.0/blobs-gif',
|
||||
// 'ttps://cdn.jsdelivr.net/gh/norevi/blob-emoji-for-waline@2.0/blobs-png'
|
||||
// ],
|
||||
// requiredMeta: ['nick', 'mail']
|
||||
// }
|
||||
}
|
||||
}
|
70
src/lib/config/projects.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
tags?: string[]
|
||||
feature?: string
|
||||
description?: string
|
||||
img: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
export const projects: Project[] = [
|
||||
// {
|
||||
// id: 'haibian',
|
||||
// name: '海边小站',
|
||||
// tags: ['Vue 3', 'Vue Router', 'Vuex', 'Axios', 'JWT', 'easyMDE', 'BootStrap'],
|
||||
// feature: 'Vue3 + TypeScript',
|
||||
// description:
|
||||
// '海边小站是一个面向成人英语学习的博文平台,为用户提供丰富的英文文摘、英语词汇教程、英语口语教程。网站有用户注册登录、博文发布编辑、用户资料更新等功能。',
|
||||
// img: 'https://usc1.contabostorage.com/cc0b816231a841b1b0232d5ef0c6deb1:image/2022/06/d4d2489936e4f647c25df6982c6ef924.png',
|
||||
// link: 'https://haibian.seviche.cc'
|
||||
// },
|
||||
{
|
||||
id: 'fokify',
|
||||
name: 'Fokify ',
|
||||
tags: ['MVC', 'Vanilla JS', 'ES6', 'Parcel', 'SCSS', 'HTML5'],
|
||||
feature: 'JavaScript',
|
||||
description:
|
||||
'一个基于Web端的菜谱搜索平台,有菜谱搜索、上传、收藏等功能,并使用 LocalStorage 来存储用户数据,让用户在退出页面后仍能浏览所收藏的菜谱。',
|
||||
img: 'https://usc1.contabostorage.com/cc0b816231a841b1b0232d5ef0c6deb1:image/2022/06/c3f41e397af1e480f57dd75e82334819.png',
|
||||
link: 'https://forkify.seviche.cc'
|
||||
},
|
||||
// {
|
||||
// id: 'Coachlist',
|
||||
// name: 'Find a Coach',
|
||||
// tags: ['Vue 3', 'Vite', 'Vuex', 'Vue Router', 'CSS Animation'],
|
||||
// feature: 'Vue3',
|
||||
// description:
|
||||
// '一个基于Vue3组合式API的师生对接的在线平台,有注册登录、筛选老师、联系老师、注册成为老师等功能。使用Vue3的transition组件和CSS动画为页面提供流畅的过渡效果',
|
||||
// img: '/assets/coach.png',
|
||||
// link: 'https://coachlist.seviche.cc'
|
||||
// }
|
||||
// ,
|
||||
{
|
||||
id: 'bankist',
|
||||
name: 'Bankist',
|
||||
tags: ['Lazy-loading'],
|
||||
feature: 'JavaScript',
|
||||
description: '一个模拟的银行官网页面,用原生JS实现了懒加载、平滑滚动,以及幻灯片等组件',
|
||||
img: 'https://usc1.contabostorage.com/cc0b816231a841b1b0232d5ef0c6deb1:image/2022/07/b5e1ff87c3b4ba4cf2f00d4124154472.png',
|
||||
link: 'https://bankist.seviche.cc'
|
||||
},
|
||||
{
|
||||
id: 'omnifood',
|
||||
name: 'Omnifood',
|
||||
tags: ['CSS5'],
|
||||
feature: 'JavaScript',
|
||||
description: '一个食品订阅APP官网,纯HTML + CSS + JavaScript实现',
|
||||
img: 'https://usc1.contabostorage.com/cc0b816231a841b1b0232d5ef0c6deb1:image/2022/07/89424d0b448d105775c1d60346c57c59.png',
|
||||
link: 'https://omnifood.seviche.cc'
|
||||
},
|
||||
{
|
||||
id: 'piggame',
|
||||
name: 'Pig Game',
|
||||
tags: ['Game'],
|
||||
feature: 'JavaScript',
|
||||
description: '一个投骰子的游戏,先累计到20的人输。',
|
||||
img: 'https://usc1.contabostorage.com/cc0b816231a841b1b0232d5ef0c6deb1:image/2022/07/154ae3bc957a478679f1d9b7e0e0dce1.png',
|
||||
link: 'https://pig-game-101.netlify.app/'
|
||||
}
|
||||
]
|
49
src/lib/config/site.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import type { SiteConfig } from '$lib/types/site'
|
||||
|
||||
export const site: SiteConfig = {
|
||||
protocol: 'https://',
|
||||
domain: 'seviche.cc',
|
||||
title: 'Seviche.cc',
|
||||
subtitle: 'Tech / Code / Random Life',
|
||||
lang: 'zh',
|
||||
description: 'Tech / Code / Random Life',
|
||||
author: {
|
||||
name: '酸橘汁腌鱼',
|
||||
avatar: '/assets/avatar.jpg',
|
||||
status: '🖤',
|
||||
bio: ' Code / Tech <br> Living a Random Life ',
|
||||
metadata: [
|
||||
{
|
||||
text: '',
|
||||
icon: 'i-mdi-github',
|
||||
link: 'https://github.com/sevichecc'
|
||||
},
|
||||
{
|
||||
text: '',
|
||||
icon: 'i-simple-icons-matrix',
|
||||
link: 'https://matrix.to/#/@seviche:kongwoo.icu'
|
||||
},
|
||||
{
|
||||
text: '',
|
||||
icon: 'i-heroicons-solid-key',
|
||||
link: 'https://keys.openpgp.org/vks/v1/by-fingerprint/76DF9F9CC0C3619AA12CB914AFF18B986818D8AD',
|
||||
rel: 'pgpkey'
|
||||
},
|
||||
{
|
||||
text: '',
|
||||
icon: 'i-ic-twotone-rss-feed',
|
||||
link: '/atom.xml',
|
||||
rel: 'rss'
|
||||
}
|
||||
// ,
|
||||
// {
|
||||
// text: 'Bookmark',
|
||||
// icon: 'i-ic-round-bookmark-border',
|
||||
// link: 'https://airtable.com/shrpftxf6JgRomP2X',
|
||||
// rel: 'bookmark'
|
||||
// }
|
||||
]
|
||||
},
|
||||
keywords: ['Tech', 'Code', 'Random Life'],
|
||||
themeColor: '#3D4451'
|
||||
}
|
4
src/lib/stores/posts.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import type { Writable } from 'svelte/store'
|
||||
import { writable } from 'svelte/store'
|
||||
export const posts: Writable<Urara.Post[]> = writable([])
|
||||
export const tags: Writable<string[]> = writable([])
|
2
src/lib/stores/title.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import { writable } from 'svelte/store'
|
||||
export const title = writable({})
|
42
src/lib/types/general.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
export type ThemeConfig = {
|
||||
text?: string
|
||||
name: string
|
||||
}[]
|
||||
|
||||
export type HeadConfig = {
|
||||
custom?: (params: { dev: boolean; post?: Urara.Post; page?: Urara.Page }) => string[]
|
||||
me?: string[]
|
||||
}
|
||||
|
||||
export type HeaderConfig = {
|
||||
nav?: {
|
||||
text: string
|
||||
link?: string
|
||||
children?: {
|
||||
text: string
|
||||
link: string
|
||||
}[]
|
||||
}[]
|
||||
search?: {
|
||||
provider: 'google' | 'duckduckgo'
|
||||
colors?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type FooterConfig = {
|
||||
nav?: {
|
||||
text: string
|
||||
link: string
|
||||
}[]
|
||||
html?: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
export type DateConfig = { locales: string; options: Intl.DateTimeFormatOptions }
|
||||
|
||||
export type FeedConfig = {
|
||||
/** feed entry limit. */
|
||||
limit?: number
|
||||
/** WebSub (formerly PubSubHubbub) hubs. one per line */
|
||||
hubs?: string[]
|
||||
}
|
5
src/lib/types/icon.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type Icon = {
|
||||
src: string
|
||||
sizes?: string
|
||||
type?: string
|
||||
}
|
119
src/lib/types/post.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import type { WalineEmojiInfo } from '@waline/client'
|
||||
type WalineImageUploader = (image: File) => Promise<string>
|
||||
|
||||
type WalineHighlighter = (code: string, lang: string) => string
|
||||
|
||||
type WalineTexRenderer = (blockMode: boolean, tex: string) => string
|
||||
|
||||
export type PostConfig = {
|
||||
bridgy?: {
|
||||
[kind: string]: ('fed' | 'mastodon' | 'flickr' | 'github' | 'twitter')[]
|
||||
}
|
||||
comment?: CommentConfig
|
||||
}
|
||||
|
||||
export type CommentConfig = {
|
||||
use: string[]
|
||||
/** tab style for multiple comments, preview at https://daisyui.com/components/tab */
|
||||
style?: 'none' | 'bordered' | 'lifted' | 'boxed'
|
||||
/** Webmention.io config, more at https://github.com/aaronpk/webmention.io#api */
|
||||
webmention?: WebmentionConfig
|
||||
/** Giscus config, more at https://giscus.app */
|
||||
giscus?: GiscusConfig
|
||||
/** Utterances config, more at https://utteranc.es */
|
||||
utterances?: UtterancesConfig
|
||||
/** Waline config, more at https://waline.js.org/en/reference/component.html#texrenderer */
|
||||
waline?: WalineConfig
|
||||
}
|
||||
|
||||
export type WebmentionConfig = {
|
||||
/** username you got when you signed up webmention.io. */
|
||||
username: string
|
||||
/** number of results per page. */
|
||||
perPage?: number
|
||||
/** sorting mechanism to return the list of mentions. */
|
||||
sortBy?: 'created' | 'updated' | 'published' | 'rsvp'
|
||||
/** control the ordering. */
|
||||
sortDir?: 'down' | 'up'
|
||||
/** find links of a specific type. */
|
||||
property?: ('in-reply-to' | 'like-of' | 'repost-of' | 'bookmark-of' | 'mention-of' | 'rsvp')[]
|
||||
/** URL array of a webmention you'd like to block. */
|
||||
blockList?: string[]
|
||||
/** show the form for sending the webmention. */
|
||||
form?: boolean
|
||||
/** show `or comment anonymously` label text. */
|
||||
commentParade?: boolean
|
||||
}
|
||||
|
||||
export type GiscusConfig = {
|
||||
/** self-hosted giscus url. */
|
||||
src?: string
|
||||
/** a public GitHub repository. this repo is where the discussions will be linked to. */
|
||||
repo: string
|
||||
/** a public GitHub repository. this repo is where the discussions will be linked to. */
|
||||
repoID: string
|
||||
/** fill in here if only search for discussions in this category. */
|
||||
category?: string
|
||||
/** choose the discussion category where new discussions will be created. */
|
||||
categoryID: string
|
||||
/** the reactions for the discussion's main post will be shown before the comments. */
|
||||
reactionsEnabled?: boolean
|
||||
/** discussion metadata will be sent periodically to the parent window (the embedding page). */
|
||||
emitMetadata?: boolean
|
||||
/** the comment input box will be placed above the comments, so that users can leave a comment without scrolling to the bottom of the discussion. */
|
||||
inputPosition?: 'top' | 'bottom'
|
||||
/** choose a theme that matches your website. */
|
||||
theme?: string
|
||||
/** choose the language giscus will be displayed in. */
|
||||
lang?: string
|
||||
/** loading of the comments will be deferred until the user scrolls near the comments container. */
|
||||
loading?: 'lazy'
|
||||
}
|
||||
|
||||
export type UtterancesConfig = {
|
||||
/** self-hosted utterances url. */
|
||||
src?: string
|
||||
/** choose the repository utterances will connect to. */
|
||||
repo: string
|
||||
/** choose the label that will be assigned to issues created by utterances. */
|
||||
label?: string
|
||||
/** choose an utterances theme that matches your blog. */
|
||||
theme?: string
|
||||
}
|
||||
|
||||
export type DisqusConfig = {
|
||||
shortname: string
|
||||
lang?: string
|
||||
}
|
||||
|
||||
// Ref:https://waline.js.org/reference/component.html
|
||||
export type WalineConfig = {
|
||||
/** Waline server address url */
|
||||
serverURL: string
|
||||
/** Article path id*/
|
||||
path?: string
|
||||
/** Display language. */
|
||||
lang?: string
|
||||
/** Emoji settings, for details see https://waline.js.org/en/guide/client/emoji.html */
|
||||
emoji?: (string | WalineEmojiInfo)[] | false
|
||||
/** Darkmode support */
|
||||
dark?: string | boolean
|
||||
/** Reviewer attributes. Optional values: 'nick', 'mail', 'link' */
|
||||
meta?: string[]
|
||||
/** Set required fields*/
|
||||
requiredMeta?: string[]
|
||||
/** login mode status */
|
||||
login?: string
|
||||
/** Comment word s limit. */
|
||||
wordLimit?: number | [number, number]
|
||||
/**number of comments per page. */
|
||||
pageSize?: number
|
||||
/** Custom image upload method. */
|
||||
imageUploader?: WalineImageUploader | false
|
||||
/** Code highlighting, use hanabi by default */
|
||||
highlighter?: WalineHighlighter | false
|
||||
/** Customize \TeX rendering */
|
||||
texRender?: WalineTexRenderer | false
|
||||
/** Whether show copyright and version in footer. */
|
||||
copyright?: boolean
|
||||
}
|
45
src/lib/types/site.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import type { FFFAuthor } from 'fff-flavored-frontmatter'
|
||||
|
||||
export type SiteConfig = {
|
||||
/** @deprecated - use `description` instead */
|
||||
descr?: string
|
||||
/** site protocol. for example: `https://` */
|
||||
protocol: string
|
||||
/** site domain. for example: `example.com` */
|
||||
domain: string
|
||||
/** site title. */
|
||||
title: string
|
||||
/** site subtitle. */
|
||||
subtitle?: string
|
||||
/** site lang. `<html lang={site.lang}>` */
|
||||
lang?: string
|
||||
/** site description. `<meta name="description" content={site.description}>` */
|
||||
description?: string
|
||||
/** site keywords. `<meta name="keywords" content={site.keywords}>` */
|
||||
keywords?: string[]
|
||||
author: Omit<FFFAuthor, 'url'> & {
|
||||
status?: string
|
||||
bio?: string
|
||||
metadata?: (
|
||||
| {
|
||||
text: string
|
||||
icon?: string
|
||||
link?: string
|
||||
rel?: string
|
||||
}
|
||||
| {
|
||||
text?: string
|
||||
icon: string
|
||||
link?: string
|
||||
rel?: string
|
||||
}
|
||||
)[]
|
||||
}
|
||||
/** for web app manifest only.
|
||||
* ```
|
||||
* "background_color": {site.themeColor},
|
||||
* "theme_color": {site.themeColor}
|
||||
* ```
|
||||
*/
|
||||
themeColor?: string
|
||||
}
|
8
src/lib/utils/case.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
export const toSnake = (str: string) =>
|
||||
str.charAt(0).toLowerCase() +
|
||||
str
|
||||
.slice(1)
|
||||
.replace(/([A-Z]+)/g, '_$1')
|
||||
.toLowerCase()
|
||||
|
||||
export const toCamel = (str: string) => str.toLowerCase().replace(/([-_][a-z])/g, g => g.slice(-1).toUpperCase())
|
11
src/lib/utils/color.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const hslToHex = (
|
||||
h: number,
|
||||
s: number,
|
||||
l: number,
|
||||
ll = (l /= 100),
|
||||
a = (s * Math.min(ll, 1 - ll)) / 100,
|
||||
f = (n: number, k = (n + h / 30) % 12) =>
|
||||
Math.round(255 * (ll - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)))
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
) => `#${f(0)}${f(8)}${f(4)}`
|
55
src/lib/utils/copyCode.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
/** copy code block to clipboard
|
||||
* @todo better transition animate
|
||||
* @todo remove dummy code
|
||||
* @todo typecheck
|
||||
*/
|
||||
export const copyCode = () => {
|
||||
const codeBlocks = document.querySelectorAll('pre')
|
||||
const copyText = 'Copy'
|
||||
const copiedText = 'Copied!'
|
||||
|
||||
//copy funciton
|
||||
const copy = async (el: HTMLElement, btn: HTMLElement) => {
|
||||
//select code
|
||||
const range = document.createRange()
|
||||
const end = el.childNodes.length
|
||||
range.setStart(el, 2)
|
||||
range.setEnd(el, end)
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
// copy to clipboard
|
||||
document.execCommand('copy', false)
|
||||
const clip = async () => navigator.clipboard.writeText(selection.toString())
|
||||
|
||||
if (!clip) return
|
||||
btn.textContent = copiedText
|
||||
setTimeout(() => {
|
||||
btn.textContent = copyText
|
||||
}, 1000)
|
||||
selection.removeRange(range)
|
||||
}
|
||||
|
||||
codeBlocks.forEach((block: HTMLElement) => {
|
||||
// add copy button
|
||||
const copyBtn: HTMLElement = document.createElement('button')
|
||||
copyBtn.textContent = copyText
|
||||
copyBtn.classList.add('btn', 'btn-secondary', 'btn-xs', 'absolute', 'right-2', 'top-3', 'hidden')
|
||||
block.prepend(copyBtn)
|
||||
|
||||
block.addEventListener('mouseenter', () => {
|
||||
copyBtn.classList.remove('hidden')
|
||||
})
|
||||
|
||||
block.addEventListener('mouseleave', () => {
|
||||
copyBtn.classList.add('hidden')
|
||||
})
|
||||
|
||||
copyBtn.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
copy(block, copyBtn)
|
||||
})
|
||||
})
|
||||
}
|
84
src/lib/utils/posts.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import type { FFFFlavoredFrontmatter } from 'fff-flavored-frontmatter'
|
||||
|
||||
interface GenPostsOptions {
|
||||
/** import.meta.glob<Urara.Post.Module> https://vitejs.dev/guide/features.html#glob-import */
|
||||
modules?: { [path: string]: Urara.Post.Module }
|
||||
/** set to true to output html */
|
||||
postHtml?: boolean
|
||||
/** limit a certain number of posts */
|
||||
postLimit?: number
|
||||
/** hide posts with 'unlisted' flag */
|
||||
filterUnlisted?: boolean
|
||||
}
|
||||
|
||||
type GenPostsFunction = (options?: GenPostsOptions) => Urara.Post[]
|
||||
|
||||
type GenTagsFunction = (posts: Urara.Post[]) => string[]
|
||||
|
||||
/**
|
||||
* Detect Post Type
|
||||
* @param fm - post frontmatter
|
||||
* @returns - post type string
|
||||
*/
|
||||
export const typeOfPost = (
|
||||
fm: FFFFlavoredFrontmatter
|
||||
): 'note' | 'article' | 'reply' | 'photo' | 'like' | 'video' | 'repost' | 'bookmark' | 'audio' =>
|
||||
fm.title
|
||||
? 'article'
|
||||
: fm.image
|
||||
? 'photo'
|
||||
: fm.audio
|
||||
? 'audio'
|
||||
: fm.video
|
||||
? 'video'
|
||||
: fm.bookmark_of
|
||||
? 'bookmark'
|
||||
: fm.like_of
|
||||
? 'like'
|
||||
: fm.repost_of
|
||||
? 'repost'
|
||||
: fm.in_reply_to
|
||||
? 'reply'
|
||||
: 'note'
|
||||
|
||||
/**
|
||||
* Generate Posts List
|
||||
* @param options - An optional configuration object
|
||||
* @returns - posts list
|
||||
*/
|
||||
export const genPosts: GenPostsFunction = ({
|
||||
modules = import.meta.glob<Urara.Post.Module>('/src/routes/**/*.{md,svelte.md}', { eager: true }),
|
||||
postHtml = false,
|
||||
postLimit = undefined,
|
||||
filterUnlisted = false
|
||||
} = {}) =>
|
||||
Object.entries(modules)
|
||||
.map(([, module]) => ({
|
||||
...module.metadata,
|
||||
type: typeOfPost(module.metadata),
|
||||
html:
|
||||
postHtml || typeOfPost(module.metadata) !== 'article'
|
||||
? module.default
|
||||
.render()
|
||||
.html // eslint-disable-next-line no-control-regex
|
||||
.replace(/[\u0000-\u001F]/g, '')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.match(/<main [^>]+>(.*?)<\/main>/gi)?.[0]
|
||||
.replace(/<main [^>]+>(.*?)<\/main>/gi, '$1')
|
||||
// .replace(/( class=")(.*?)(")/gi, '')
|
||||
.replace(/( style=")(.*?)(")/gi, '')
|
||||
.replace(/(<span>)(.*?)(<\/span>)/gi, '$2')
|
||||
.replace(/(<main>)(.*?)(<\/main>)/gi, '$2')
|
||||
: ''
|
||||
}))
|
||||
.filter((post, index) => (!filterUnlisted || !post.flags?.includes('unlisted')) && (!postLimit || index < postLimit))
|
||||
.sort((a, b) => Date.parse(b.published ?? b.created) - Date.parse(a.published ?? a.created))
|
||||
|
||||
/**
|
||||
* Generate Tags List
|
||||
* @param posts - posts list
|
||||
* @returns - tags list
|
||||
*/
|
||||
export const genTags: GenTagsFunction = posts => [
|
||||
...new Set(posts.reduce((acc, posts) => (posts.tags ? [...acc, ...posts.tags] : acc), ['']).slice(1))
|
||||
]
|
38
src/routes/__error.svelte
Normal file
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts" context="module">
|
||||
import type { Load } from './__types'
|
||||
export const load: Load = ({ url: { pathname }, error, status }) => ({ props: { pathname, error, status } })
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Head from '$lib/components/head.svelte'
|
||||
import Footer from '$lib/components/footer.svelte'
|
||||
export let pathname: string
|
||||
export let error: Error
|
||||
export let status: string
|
||||
console.error(status, error.message)
|
||||
</script>
|
||||
|
||||
<Head page={{ title: status ?? '404', path: pathname ?? '/404' }} />
|
||||
|
||||
<div class="flex flex-col flex-nowrap justify-center xl:flex-row xl:flex-wrap">
|
||||
<div class="flex-none w-full max-w-screen-md mx-auto xl:mx-0">
|
||||
<article
|
||||
itemscope
|
||||
itemtype="https://schema.org/BlogPosting"
|
||||
class="card bg-base-100 rounded-none md:rounded-box shadow-xl md:mb-8 z-10">
|
||||
<main itemprop="articleBody" class="card-body prose urara-prose">
|
||||
<h1 class="opacity-20 text-6xl md:text-[12rem] -mt-2 mb-0">
|
||||
{status ?? '404'}
|
||||
</h1>
|
||||
<h2 class="-mt-12 md:-mt-24">{error.message ?? 'Not found'}</h2>
|
||||
<div class="card-actions">
|
||||
<a href="/" class="btn btn-neutral no-underline shadow-xl hover:shadow-2xl mt-8">
|
||||
<span class="i-heroicons-outline-home -ml-1 mr-2" />
|
||||
Back to Home
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</article>
|
||||
<Footer sticky={true} class="flex-1 md:flex-initial" />
|
||||
</div>
|
||||
</div>
|
49
src/routes/__layout.svelte
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts" context="module">
|
||||
import type { Load } from './__types'
|
||||
export const prerender = true
|
||||
export const load: Load = async ({ url, fetch }) => ({
|
||||
props: {
|
||||
path: url.pathname,
|
||||
res: await fetch('/posts.json').then(res => res.json())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { browser, dev } from '$app/env'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { genTags } from '$lib/utils/posts'
|
||||
import { posts, tags } from '$lib/stores/posts'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import Head from '$lib/components/head_static.svelte'
|
||||
import Header from '$lib/components/header.svelte'
|
||||
import 'uno.css'
|
||||
import '../app.css'
|
||||
export let res: Urara.Post[]
|
||||
export let path: string
|
||||
posts.set(res)
|
||||
tags.set(genTags(res))
|
||||
onMount(
|
||||
() =>
|
||||
!dev &&
|
||||
browser &&
|
||||
registerSW({
|
||||
onRegistered: r => r && setInterval(async () => await r.update(), 198964),
|
||||
onRegisterError: error => console.error(error)
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<Head />
|
||||
|
||||
<Header {path} />
|
||||
|
||||
{#key path}
|
||||
<div
|
||||
class="bg-base-100 md:bg-base-200 min-h-screen pt-16 md:pb-8 lg:pb-16"
|
||||
in:fly={{ y: 100, duration: 300, delay: 300 }}
|
||||
out:fly={{ y: -100, duration: 300 }}>
|
||||
<slot />
|
||||
</div>
|
||||
{/key}
|
49
src/routes/atom.xml.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { site } from '$lib/config/site'
|
||||
import { feed } from '$lib/config/general'
|
||||
import { favicon } from '$lib/config/icon'
|
||||
import { genPosts, genTags } from '$lib/utils/posts'
|
||||
|
||||
const render = async (
|
||||
posts = genPosts({ postHtml: true, postLimit: feed.limit, filterUnlisted: true })
|
||||
): Promise<string> => `<?xml version='1.0' encoding='utf-8'?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<id>${site.protocol + site.domain}/</id>
|
||||
<title><![CDATA[${site.title}]]></title>${site.subtitle ? `\n <subtitle><![CDATA[${site.subtitle}]]></subtitle>` : ''}${
|
||||
favicon ? `\n <icon>${favicon.src}</icon>` : ''
|
||||
}
|
||||
<link href="${site.protocol + site.domain}" />
|
||||
<link href="${site.protocol + site.domain}/atom.xml" rel="self" type="application/atom+xml" />${
|
||||
feed.hubs?.map(hub => `\n <link href="${hub}" rel="hub"/>`).join('') ?? ''
|
||||
}
|
||||
<updated>${new Date().toJSON()}</updated>
|
||||
<author>
|
||||
<name><![CDATA[${site.author.name}]]></name>
|
||||
</author>${genTags(posts)
|
||||
.map(tag => `\n <category term="${tag}" scheme="${site.protocol + site.domain}/?tags=${encodeURI(tag)}" />`)
|
||||
.join('')}${posts
|
||||
.map(
|
||||
post => `\n <entry>
|
||||
<title type="html"><![CDATA[${post.title}]]></title>
|
||||
<link href="${site.protocol + site.domain + post.path}" />
|
||||
<id>${site.protocol + site.domain + post.path}</id>
|
||||
<published>${new Date(post.published ?? post.created).toJSON()}</published>
|
||||
<updated>${new Date(post.updated ?? post.published ?? post.created).toJSON()}</updated>${
|
||||
post.summary ? `\n <summary type="html"><![CDATA[${post.summary.toString()}]]></summary>` : ''
|
||||
}
|
||||
<content type="html">
|
||||
<![CDATA[${post.html}]]>
|
||||
</content>${post.tags
|
||||
?.map(tag => `\n <category term="${tag}" scheme="${site.protocol + site.domain}/?tags=${encodeURI(tag)}" />`)
|
||||
.join('')}
|
||||
</entry>`
|
||||
)
|
||||
.join('')}
|
||||
</feed>`
|
||||
|
||||
export const GET: RequestHandler = async () => ({
|
||||
headers: {
|
||||
'Content-Type': 'application/atom+xml; charset=utf-8'
|
||||
},
|
||||
body: await render()
|
||||
})
|
49
src/routes/feed.json.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { site } from '$lib/config/site'
|
||||
import { feed } from '$lib/config/general'
|
||||
import { favicon, any } from '$lib/config/icon'
|
||||
import { genPosts } from '$lib/utils/posts'
|
||||
|
||||
const render = async (posts = genPosts({ postHtml: true, postLimit: feed.limit, filterUnlisted: true })) => ({
|
||||
version: 'https://jsonfeed.org/version/1.1',
|
||||
title: site.title,
|
||||
home_page_url: site.protocol + site.domain,
|
||||
feed_url: site.protocol + site.domain + '/feed.json',
|
||||
description: site.description,
|
||||
icon: any['512'].src ?? any['192'].src,
|
||||
favicon: favicon?.src,
|
||||
authors: [
|
||||
{
|
||||
name: site.author.name,
|
||||
url: site.protocol + site.domain,
|
||||
avatar: site.author.avatar
|
||||
}
|
||||
],
|
||||
language: site.lang ?? 'en',
|
||||
hubs: feed.hubs?.map(hub => ({
|
||||
type: 'WebSub',
|
||||
url: hub
|
||||
})),
|
||||
items: posts.map(post => ({
|
||||
id: post.path.slice(1),
|
||||
url: site.protocol + site.domain + post.path,
|
||||
title: post.title,
|
||||
content_html: post.html,
|
||||
summary: post['summary'],
|
||||
image: post['image'],
|
||||
date_published: post.published ?? post.created,
|
||||
date_modified: post.updated ?? post.published ?? post.created,
|
||||
tags: post.tags,
|
||||
_indieweb: {
|
||||
type: post.type,
|
||||
'in-reply-to': post.in_reply_to
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
export const GET: RequestHandler = async () => ({
|
||||
headers: {
|
||||
'Content-Type': 'application/feed+json; charset=utf-8'
|
||||
},
|
||||
body: JSON.stringify(await render(), null, 2)
|
||||
})
|
129
src/routes/index.svelte
Normal file
|
@ -0,0 +1,129 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fly } from 'svelte/transition'
|
||||
import { page } from '$app/stores'
|
||||
import { browser } from '$app/env'
|
||||
import { posts as storedPosts, tags as storedTags } from '$lib/stores/posts'
|
||||
import { title as storedTitle } from '$lib/stores/title'
|
||||
import Head from '$lib/components/head.svelte'
|
||||
import Footer from '$lib/components/footer.svelte'
|
||||
import Post from '$lib/components/post_card.svelte'
|
||||
// import Post from '$lib/components/index_post.svelte'
|
||||
import Profile from '$lib/components/index_profile.svelte'
|
||||
import RemoteFollow from '$lib/components/extra/follow.svelte'
|
||||
|
||||
let allPosts: Urara.Post[]
|
||||
let allTags: string[]
|
||||
let loaded: boolean
|
||||
let [posts, tags, years]: [Urara.Post[], string[], number[]] = [[], [], []]
|
||||
|
||||
storedTitle.set('')
|
||||
|
||||
$: storedPosts.subscribe(
|
||||
storedPosts => (allPosts = (storedPosts as Urara.Post[]).filter(post => !post.flags?.includes('unlisted')))
|
||||
)
|
||||
|
||||
$: storedTags.subscribe(storedTags => (allTags = storedTags as string[]))
|
||||
|
||||
$: if (posts.length > 1) years = [new Date(posts[0].published ?? posts[0].created).getFullYear()]
|
||||
|
||||
$: if (tags) {
|
||||
posts = !tags ? allPosts : allPosts.filter(post => tags.every(tag => post.tags?.includes(tag)))
|
||||
if (browser && window.location.pathname === '/')
|
||||
window.history.replaceState({}, '', tags.length > 0 ? `?tags=${tags.toString()}` : `/`)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
if ($page.url.searchParams.get('tags')) tags = $page.url.searchParams.get('tags')?.split(',') ?? []
|
||||
loaded = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Head />
|
||||
|
||||
<div class="flex flex-col flex-nowrap justify-center xl:flex-row xl:flex-wrap h-feed">
|
||||
<div
|
||||
in:fly={{ x: 25, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: 25, duration: 300 }}
|
||||
class="flex-1 w-full max-w-screen-md order-first mx-auto xl:mr-0 xl:ml-8 xl:max-w-md">
|
||||
<Profile />
|
||||
</div>
|
||||
<div
|
||||
in:fly={{ x: -25, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: -25, duration: 300 }}
|
||||
class="flex-1 w-full max-w-screen-md xl:order-last mx-auto xl:ml-0 xl:mr-8 xl:max-w-md">
|
||||
{#if allTags && Object.keys(allTags).length > 0}
|
||||
<div
|
||||
class="flex xl:flex-wrap gap-2 overflow-x-auto xl:overflow-x-hidden overflow-y-hidden max-h-24 my-auto xl:max-h-fit max-w-fit xl:max-w-full pl-8 md:px-0 xl:pl-8 xl:pt-8">
|
||||
{#each allTags as tag}
|
||||
<button
|
||||
id={tag}
|
||||
on:click={() => (tags.includes(tag) ? (tags = tags.filter(tagName => tagName != tag)) : (tags = [...tags, tag]))}
|
||||
class:!btn-secondary={tags.includes(tag)}
|
||||
class:shadow-lg={tags.includes(tag)}
|
||||
class="btn btn-sm btn-ghost normal-case border-dotted border-base-content/20 border-2 mt-4 mb-8 xl:m-0">
|
||||
#{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-none w-full max-w-screen-md mx-auto xl:mx-0">
|
||||
{#key posts}
|
||||
<!-- {:else} is not used because there is a problem with the transition -->
|
||||
{#if loaded && posts.length === 0}
|
||||
<div
|
||||
in:fly={{ x: 100, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: -100, duration: 300 }}
|
||||
class="bg-base-300 text-base-content shadow-inner text-center md:rounded-box p-10 -mb-2 md:mb-0 relative z-10">
|
||||
<div class="prose items-center">
|
||||
<h2>
|
||||
Not found: [{#each tags as tag, i}
|
||||
'{tag}'{#if i + 1 < tags.length},{/if}
|
||||
{/each}]
|
||||
</h2>
|
||||
<button on:click={() => (tags = [])} class="btn btn-secondary">
|
||||
<span class="i-heroicons-outline-trash mr-2" />
|
||||
tags = []
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<main
|
||||
class="flex flex-col relative bg-base-100 md:bg-transparent md:gap-8 z-10"
|
||||
itemprop="mainEntityOfPage"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Blog">
|
||||
{#each posts as post, index}
|
||||
{@const year = new Date(post.published ?? post.created).getFullYear()}
|
||||
{#if !years.includes(year)}
|
||||
<div
|
||||
in:fly={{ x: index % 2 ? 100 : -100, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: index % 2 ? -100 : 100, duration: 300 }}
|
||||
class="divider my-4 md:my-0">
|
||||
{years.push(year) && year}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
in:fly={{ x: index % 2 ? 100 : -100, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: index % 2 ? -100 : 100, duration: 300 }}
|
||||
class="rounded-box transition-all duration-500 ease-in-out hover:z-30 hover:shadow-lg md:shadow-xl md:hover:shadow-2xl md:hover:-translate-y-0.5">
|
||||
<Post {post} preview={true} loading={index < 5 ? 'eager' : 'lazy'} decoding={index < 5 ? 'auto' : 'async'} />
|
||||
</div>
|
||||
{/each}
|
||||
</main>
|
||||
<div
|
||||
class:hidden={!loaded}
|
||||
class="sticky bottom-0 md:static md:mt-8"
|
||||
in:fly={{ x: posts.length + (1 % 2) ? 100 : -100, duration: 300, delay: 500 }}
|
||||
out:fly={{ x: posts.length + (1 % 2) ? -100 : 100, duration: 300 }}>
|
||||
<div class="divider mt-0 mb-8 hidden lg:flex" />
|
||||
<Footer />
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RemoteFollow />
|
32
src/routes/manifest.webmanifest.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { site } from '$lib/config/site'
|
||||
import { any, maskable } from '$lib/config/icon'
|
||||
|
||||
export const GET: RequestHandler = () => ({
|
||||
headers: {
|
||||
'Content-Type': 'application/manifest+json; charset=utf-8'
|
||||
},
|
||||
body: JSON.stringify(
|
||||
{
|
||||
name: site.title,
|
||||
short_name: site.title,
|
||||
lang: site.lang,
|
||||
description: site.description,
|
||||
id: site.protocol + site.domain + '/',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: site.themeColor,
|
||||
theme_color: site.themeColor,
|
||||
icons: [
|
||||
...Object.values(any)
|
||||
.filter(icon => icon.sizes !== '180x180')
|
||||
.map(icon => ({ ...icon, purpose: 'any' })),
|
||||
...Object.values(maskable).map(icon => ({ ...icon, purpose: 'maskable' }))
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
})
|
9
src/routes/posts.json.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { genPosts } from '$lib/utils/posts'
|
||||
|
||||
export const GET: RequestHandler = async () => ({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
},
|
||||
body: JSON.stringify(genPosts(), null, 2)
|
||||
})
|
27
src/routes/sitemap.xml.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { site } from '$lib/config/site'
|
||||
import { genPosts } from '$lib/utils/posts'
|
||||
|
||||
const render = async (): Promise<string> => `<?xml version='1.0' encoding='utf-8'?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>${site.protocol + site.domain}</loc>
|
||||
</url>
|
||||
${genPosts()
|
||||
.map(
|
||||
post => `
|
||||
<url>
|
||||
<loc>${site.protocol + site.domain + post.path}</loc>
|
||||
<lastmod>${new Date(post.updated ?? post.published ?? post.created).toISOString()}</lastmod>
|
||||
<priority>0.5</priority>
|
||||
</url>`
|
||||
)
|
||||
.join('')}
|
||||
</urlset>`
|
||||
|
||||
export const GET: RequestHandler = async () => ({
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8'
|
||||
},
|
||||
body: await render()
|
||||
})
|
9
src/routes/tags.json.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { genPosts, genTags } from '$lib/utils/posts'
|
||||
|
||||
export const GET: RequestHandler = async () => ({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8'
|
||||
},
|
||||
body: JSON.stringify(genTags(genPosts()), null, 2)
|
||||
})
|
29
svelte.config.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
// sveltekit config type
|
||||
import type { Config } from '@sveltejs/kit'
|
||||
// svelte preprocess
|
||||
import preprocess from 'svelte-preprocess'
|
||||
import adapterAuto from '@sveltejs/adapter-auto'
|
||||
import adapterNode from '@sveltejs/adapter-node'
|
||||
import adapterStatic from '@sveltejs/adapter-static'
|
||||
import { mdsvex } from 'mdsvex'
|
||||
import mdsvexConfig from './mdsvex.config.js'
|
||||
|
||||
const defineConfig = (config: Config) => config
|
||||
|
||||
export default defineConfig({
|
||||
extensions: ['.svelte', ...(mdsvexConfig.extensions as string[])],
|
||||
preprocess: [mdsvex(mdsvexConfig), preprocess()],
|
||||
kit: {
|
||||
adapter: Object.keys(process.env).some(key => ['VERCEL', 'CF_PAGES', 'NETLIFY'].includes(key))
|
||||
? adapterAuto()
|
||||
: process.env.ADAPTER === 'node'
|
||||
? adapterNode({ out: 'build' })
|
||||
: adapterStatic({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: undefined
|
||||
}),
|
||||
csp: { mode: 'auto' },
|
||||
prerender: { default: true }
|
||||
}
|
||||
})
|
63
tailwind.config.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
// tailwind config type
|
||||
import type { TailwindConfig } from 'tailwindcss/tailwind-config'
|
||||
// @ts-ignore TS2305: Module 'tailwindcss/plugin' has no exported member 'TailwindPluginWithoutOptions'.
|
||||
import type { TailwindPluginWithoutOptions } from 'tailwindcss/plugin'
|
||||
// tailwind plugins
|
||||
import typography from '@tailwindcss/typography'
|
||||
import daisyui from 'daisyui'
|
||||
|
||||
interface Config extends TailwindConfig {
|
||||
daisyui?: {
|
||||
styled?: boolean
|
||||
themes?: boolean | string[]
|
||||
base?: boolean
|
||||
utils?: boolean
|
||||
logs?: boolean
|
||||
rtl?: boolean
|
||||
darkTheme?: string
|
||||
prefix?: string
|
||||
}
|
||||
}
|
||||
|
||||
const defineConfig = (config: Config) => config
|
||||
|
||||
export default defineConfig({
|
||||
content: ['./src/**/*.{html,md,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [typography as TailwindPluginWithoutOptions, daisyui as TailwindPluginWithoutOptions],
|
||||
daisyui: {
|
||||
themes: [
|
||||
'light',
|
||||
'dark',
|
||||
'cupcake',
|
||||
'bumblebee',
|
||||
'emerald',
|
||||
'corporate',
|
||||
'synthwave',
|
||||
'retro',
|
||||
'cyberpunk',
|
||||
'valentine',
|
||||
'halloween',
|
||||
'garden',
|
||||
'forest',
|
||||
'aqua',
|
||||
'lofi',
|
||||
'pastel',
|
||||
'fantasy',
|
||||
'wireframe',
|
||||
'black',
|
||||
'luxury',
|
||||
'dracula',
|
||||
'cmyk',
|
||||
'autumn',
|
||||
'business',
|
||||
'acid',
|
||||
'lemonade',
|
||||
'night',
|
||||
'coffee',
|
||||
'winter'
|
||||
]
|
||||
}
|
||||
})
|
14
tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
||||
}
|
||||
}
|
11
tsconfig.node.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"declaration": false,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["urara.ts", "svelte.config.ts", "mdsvex.config.ts"]
|
||||
}
|
140
urara.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Urara.TS
|
||||
* Version: Any
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
import chokidar from 'chokidar'
|
||||
import chalk from 'chalk'
|
||||
|
||||
const config = {
|
||||
extensions: ['svelte', 'md', 'js', 'ts'],
|
||||
catch: ['ENOENT', 'EEXIST']
|
||||
}
|
||||
|
||||
const check = (ext: string) => (config.extensions.includes(ext) ? 'src/routes' : 'static')
|
||||
|
||||
const log = (color: string, msg: string, dest?: string | Error) =>
|
||||
console.log(
|
||||
chalk.dim(new Date().toLocaleTimeString() + ' ') +
|
||||
chalk.magentaBright.bold('[urara] ') +
|
||||
chalk[color](msg + ' ') +
|
||||
chalk.dim(dest ?? '')
|
||||
)
|
||||
|
||||
const error = (err: { code: string; message: unknown }) => {
|
||||
if (config.catch.includes(err.code)) {
|
||||
console.log(
|
||||
chalk.dim(new Date().toLocaleTimeString() + ' ') +
|
||||
chalk.redBright.bold('[urara] ') +
|
||||
chalk.red('error ') +
|
||||
chalk.dim(err.message)
|
||||
)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const cpFile = (src: string, { stat = 'copy', dest = path.join(check(path.parse(src).ext.slice(1)), src.slice(6)) } = {}) =>
|
||||
fs
|
||||
.copyFile(src, dest)
|
||||
.then(() => log('green', `${stat} file`, dest))
|
||||
.catch(error)
|
||||
|
||||
const rmFile = (src: string, { dest = path.join(check(path.parse(src).ext.slice(1)), src.slice(6)) } = {}) =>
|
||||
fs
|
||||
.rm(dest)
|
||||
.then(() => log('yellow', 'remove file', dest))
|
||||
.catch(error)
|
||||
|
||||
const cpDir = (src: string) =>
|
||||
fs.readdir(src, { withFileTypes: true }).then(files =>
|
||||
files.forEach(file => {
|
||||
const dest = path.join(src, file.name)
|
||||
if (file.isDirectory()) {
|
||||
mkDir(dest)
|
||||
cpDir(dest)
|
||||
} else if (file.name.startsWith('.')) {
|
||||
log('cyan', 'ignore file', dest)
|
||||
} else {
|
||||
cpFile(dest)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const mkDir = (src: string, { dest = [path.join('src/routes', src.slice(6)), path.join('static', src.slice(6))] } = {}) => {
|
||||
dest.forEach(path =>
|
||||
fs
|
||||
.mkdir(path)
|
||||
.then(() => log('green', 'make dir', path))
|
||||
.catch(error)
|
||||
)
|
||||
}
|
||||
|
||||
const rmDir = (src: string, { dest = [path.join('src/routes', src.slice(6)), path.join('static', src.slice(6))] } = {}) => {
|
||||
dest.forEach(path =>
|
||||
fs
|
||||
.rm(path, { force: true, recursive: true })
|
||||
.then(() => log('yellow', 'remove dir', path))
|
||||
.catch(error)
|
||||
)
|
||||
}
|
||||
|
||||
const cleanDir = (src: string) =>
|
||||
fs.readdir(src, { withFileTypes: true }).then(files => {
|
||||
files.forEach(file => {
|
||||
const dest = path.join(src, file.name)
|
||||
file.isDirectory() ? rmDir(dest) : file.name.startsWith('.') ? log('cyan', 'ignore file', dest) : rmFile(dest)
|
||||
})
|
||||
})
|
||||
|
||||
const build = () => {
|
||||
mkDir('static', { dest: ['static'] })
|
||||
cpDir('urara')
|
||||
}
|
||||
|
||||
const clean = () => {
|
||||
cleanDir('urara')
|
||||
rmDir('static', { dest: ['static'] })
|
||||
}
|
||||
|
||||
switch (process.argv[2]) {
|
||||
case 'watch':
|
||||
{
|
||||
let watcher = chokidar.watch('urara', {
|
||||
ignored: (file: string) => path.basename(file).startsWith('.')
|
||||
})
|
||||
watcher
|
||||
.on('add', file => cpFile(file))
|
||||
.on('change', file => cpFile(file, { stat: 'update' }))
|
||||
.on('unlink', file => rmFile(file))
|
||||
.on('addDir', dir => mkDir(dir))
|
||||
.on('unlinkDir', dir => rmDir(dir))
|
||||
.on('error', error => log('red', 'error', error))
|
||||
.on('ready', () => log('cyan', 'copy complete. ready for changes'))
|
||||
process
|
||||
.on('SIGINT', () => {
|
||||
log('red', 'sigint')
|
||||
clean()
|
||||
watcher?.close()
|
||||
})
|
||||
.on('SIGTERM', () => {
|
||||
log('red', 'sigterm')
|
||||
watcher?.close()
|
||||
})
|
||||
.on('exit', () => {
|
||||
log('red', 'exit')
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'build':
|
||||
build()
|
||||
break
|
||||
case 'clean':
|
||||
clean()
|
||||
break
|
||||
default:
|
||||
log('red', 'error', 'invalid arguments')
|
||||
break
|
||||
}
|
BIN
urara/2021-11-08-ux1/1.png
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
urara/2021-11-08-ux1/2.png
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
urara/2021-11-08-ux1/3.png
Normal file
After Width: | Height: | Size: 110 KiB |
BIN
urara/2021-11-08-ux1/4.png
Normal file
After Width: | Height: | Size: 36 KiB |
132
urara/2021-11-08-ux1/index.md
Normal file
|
@ -0,0 +1,132 @@
|
|||
---
|
||||
title: Overview of User Experience Design
|
||||
summary: Introduction to User Experience Design|Week1
|
||||
created: 2021-11-08T10:54:40+08:00
|
||||
# categories:
|
||||
# - UX学习笔记
|
||||
# - Introduction to User Experience Design
|
||||
tags:
|
||||
- UX
|
||||
---
|
||||
|
||||
## 课程简介
|
||||
|
||||
这是一门在 [Coursera](https://www.coursera.org/learn/user-experience-design) 上的 UX 课程,共有五个星期的内容,讲了 UX 的基本概念和实际操作流程,主要侧重用户研究方面,也讲了很多和设计心理学有关的内容,很适合 UX 入门。
|
||||
|
||||
我将按照课程内容的划分,分别发布五个 weeks 的笔记,这是第一篇。
|
||||
|
||||
原笔记是在 Notion 上写的,我喜欢用 Notion 里面的 toggle 效果,用“提问”-“答案”的形式来启发思考,但是博客这里用折叠文本比较麻烦,所以直接列出来了
|
||||
|
||||
## Week 1 笔记
|
||||
|
||||
### 什么是 ux?
|
||||
|
||||
> User Experience design is design that is user centered。The goal is to design artifacts that allow the users to meet their needs in the most effective efficient and satisfying manner。
|
||||
>
|
||||
> **以用户为中心的设计。目标是轻松高效地满足用户的需求。**
|
||||
|
||||
### 用户体验设计的核心概念
|
||||
|
||||
> “用户使用界面来完成任务”
|
||||
|
||||
通过理解用户以及他们要完成的任务,以便设计出最好的界面 (interface)
|
||||
|
||||
### 什么是用户
|
||||
|
||||
这里的“用户”是指使用一些技术来达到目的的个体
|
||||
|
||||
### 什么是界面
|
||||
|
||||
有输入、输出、系统,于此同时,每个输入都会导致一个期望的输出:
|
||||

|
||||
个人使用界面的能力与个人特征、群体、社会有关
|
||||

|
||||
|
||||
### 用户体验设计的目标
|
||||
|
||||

|
||||
|
||||
**设计“能用”并且“好用”的界面**
|
||||
|
||||
- “**能用**”的意思是:它可以让用户完成一个任务。在图中,这个系统 (S) 就产生了期望的成果 (Output)
|
||||
- “**好用**”的意思是:用户可以有效高效甚至满意地达成这项任务。在我们的图表中,这需要轻松快速地输入 (Input) 并且输出 (Output) 的结果有效的完成了任务
|
||||
|
||||
### 界面设计环 (四个步骤)
|
||||
|
||||

|
||||
|
||||
#### Step1:Requirements Gathering 收集需求
|
||||
|
||||
- Understanding the user and what her goals are,What are the current practices
|
||||
- This step can also be thought of as understanding the**“problem space”**- what is hindering the completion of the task how can the task or process be improved
|
||||
- A whole host of techniques are presented that allow the designer to collect data about the user,her goals and current practices
|
||||
|
||||
#### Step 2:Design Alternatives 设计方案
|
||||
|
||||
- Once you understand the users,their goals,and their current practices (e.i., the problem space) you are able to take this data and **develop various design options** that will improve the user experience
|
||||
|
||||
#### Step 3:Prototyping 原型
|
||||
|
||||
- Techniques for modeling the novel designs before a final version is produced
|
||||
|
||||
#### Step 4:Evaluation 评估测试
|
||||
|
||||
- A set of techniques for ascertaining that your design meets the needs of the user
|
||||
|
||||
### 什么样的界面是“实用”的?
|
||||
|
||||
“实用性”指的是有效、高效并且让用户觉得满意的。
|
||||
|
||||
> 如果用户能够理解怎样的输入 (Input) 会带来所需的输出 (Output) 那么这个界面就是实用的
|
||||
|
||||
### 好设计的三个特征
|
||||
|
||||

|
||||
|
||||
#### 1 - Affordance (示能)
|
||||
|
||||
> 指一个物理对象与人之间的关系
|
||||
>
|
||||
> (无论是动物还是人类,甚至机器和机器人,他们之间发生的任何交互作用)
|
||||
|
||||
是指可以感知到的事物的实际属性,主要是那些决定事物如何被使用的基本属性 (看看你的智能手机,我们会看到很多种功能,例如按键,可以感知到可以被按。)
|
||||
|
||||
#### 2 - Signifiers (意符)
|
||||
|
||||
> 能告诉人们正确操作方式的任何可感知的标记或声音。
|
||||
|
||||
示能决定可能进行哪些操作,意符则点名操作的位置。
|
||||
|
||||
#### 3 - Feedback (反馈)
|
||||
|
||||
> 一些让你知道系统正在处理你的要求的方式
|
||||
|
||||
反馈需要将用户信息发送回来其中包括哪些系统输入的信息已发生它跟我们沟通操作结果
|
||||
|
||||
- 输入:示能、意符
|
||||
- 输出:示能
|
||||
|
||||
### 如何与“用户”接洽
|
||||
|
||||
注意礼貌、着装
|
||||
|
||||
#### 三个步骤
|
||||
|
||||
**1 - 介绍**
|
||||
|
||||
- 简要概述目标
|
||||
- 坦率:让用户知道该会话的目标是什么。询问他们坦率的意见。
|
||||
- 保密:说明交互内容是保密的。
|
||||
- 自愿:在介绍期间,向用户解释他们的参与完全是自愿的。他们可以随时停止参与。如果他们希望停止参与,这不会消极地影响他们与你的公司或机构的关系。这一部分是很重要的,它能让用户放心并能够不受约束,因为你可能代表一个在社区中具有较高知名度的实体
|
||||
- 开放:让他们知道,没有正确或错误的答案,他们应该简单直接地向你提供反馈和意见
|
||||
|
||||
**2 - 交流**
|
||||
|
||||
- 中立态度、语气轻松专业。
|
||||
- 如果形式允许,鼓励详细阐述意外或有趣的信息
|
||||
- 保持对交流过程的控制,尽量覆盖所有话题
|
||||
|
||||
**3 - 结束**
|
||||
|
||||
- 在会话结束时,提醒他们有关交互的目标和您打算如何处理他们提供的数据。
|
||||
- 询问他们是否还有需要补充的内容。
|
BIN
urara/2021-11-09-ux2/1.png
Normal file
After Width: | Height: | Size: 147 KiB |
BIN
urara/2021-11-09-ux2/10.png
Normal file
After Width: | Height: | Size: 138 KiB |
BIN
urara/2021-11-09-ux2/11.png
Normal file
After Width: | Height: | Size: 116 KiB |
BIN
urara/2021-11-09-ux2/12.png
Normal file
After Width: | Height: | Size: 120 KiB |
BIN
urara/2021-11-09-ux2/13.png
Normal file
After Width: | Height: | Size: 88 KiB |