Compare commits
56 Commits
new-home-backup
...
astro
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8f9824d9 | |||
| 9f3fb28b99 | |||
| 1fb6522895 | |||
| ea9c12eda5 | |||
| f25f468c99 | |||
| 495e106e4c | |||
| e52ef61c8b | |||
| 51157cae08 | |||
| 9b5a613593 | |||
| ab474204ce | |||
| 6c776c8a2e | |||
| fafe05a792 | |||
| c7303bd5fd | |||
| 2f6272818d | |||
| 58d0cb99b8 | |||
| 84128c52a5 | |||
| 34b076244b | |||
| 07a5ba0f75 | |||
| 285fe5d2de | |||
| 13da185b18 | |||
| ba52340363 | |||
| b65fdc6f99 | |||
| 0fc709709b | |||
| 2fbb36b830 | |||
| 0d42bfba2b | |||
| 86fc1a365e | |||
| 2ff45e1068 | |||
| 835bccf288 | |||
| db1b257df4 | |||
| 799f8515ec | |||
| 22e15e6beb | |||
| 51682f81a8 | |||
| 6cd3484f7c | |||
| db5bcc66ec | |||
| a4f6e21af8 | |||
| 9339af97d5 | |||
| 26e19af0a3 | |||
| 7cc6a57b49 | |||
| 4fbe9f156b | |||
| ab6919ca28 | |||
| 4f128a6fb8 | |||
| 8b8c6db10f | |||
| fd07dd04f7 | |||
| 848a8ddf91 | |||
| 473a84b91e | |||
| b68031238f | |||
| d1525096da | |||
| 83ba8b9f93 | |||
| 56ca0ab655 | |||
| d613ac62aa | |||
| 6ebd786b7c | |||
| 747d725c43 | |||
| 731c08dbcb | |||
| 0143b1edc9 | |||
| ac9bea5fd2 | |||
| 58462de365 |
Vendored
+154
@@ -0,0 +1,154 @@
|
||||
declare module 'astro:content' {
|
||||
export interface RenderResult {
|
||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
remarkPluginFrontmatter: Record<string, any>;
|
||||
}
|
||||
interface Render {
|
||||
'.md': Promise<RenderResult>;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
html: string;
|
||||
metadata?: {
|
||||
imagePaths: Array<string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||
|
||||
export type CollectionKey = keyof DataEntryMap;
|
||||
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
|
||||
|
||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||
|
||||
export type ReferenceDataEntry<
|
||||
C extends CollectionKey,
|
||||
E extends keyof DataEntryMap[C] = string,
|
||||
> = {
|
||||
collection: C;
|
||||
id: E;
|
||||
};
|
||||
|
||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||
collection: C;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||
): Promise<E[]>;
|
||||
export function getCollection<C extends keyof DataEntryMap>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter?: LiveLoaderCollectionFilterType<C>,
|
||||
): Promise<
|
||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||
>;
|
||||
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
entry: ReferenceDataEntry<C, E>,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
id: E,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? string extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]> | undefined
|
||||
: Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter: string | LiveLoaderEntryFilterType<C>,
|
||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||
|
||||
/** Resolve an array of entry references from the same collection */
|
||||
export function getEntries<C extends keyof DataEntryMap>(
|
||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function render<C extends keyof DataEntryMap>(
|
||||
entry: DataEntryMap[C][string],
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function reference<
|
||||
C extends
|
||||
| keyof DataEntryMap
|
||||
// Allow generic `string` to avoid excessive type errors in the config
|
||||
// if `dev` is not running to update as you edit.
|
||||
// Invalid collection names will be caught at build time.
|
||||
| (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodPipe<
|
||||
import('astro/zod').ZodString,
|
||||
import('astro/zod').ZodTransform<
|
||||
C extends keyof DataEntryMap
|
||||
? {
|
||||
collection: C;
|
||||
id: string;
|
||||
}
|
||||
: never,
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
|
||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
|
||||
type InferLoaderSchema<
|
||||
C extends keyof DataEntryMap,
|
||||
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
|
||||
> = L extends { schema: import('astro/zod').ZodSchema }
|
||||
? import('astro/zod').infer<L['schema']>
|
||||
: any;
|
||||
|
||||
type DataEntryMap = {
|
||||
|
||||
};
|
||||
|
||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||
infer TData,
|
||||
infer TEntryFilter,
|
||||
infer TCollectionFilter,
|
||||
infer TError
|
||||
>
|
||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||
|
||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||
: import('astro/zod').infer<
|
||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||
>;
|
||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||
LiveContentConfig['collections'][C]['loader']
|
||||
>;
|
||||
|
||||
export type ContentConfig = never;
|
||||
export type LiveContentConfig = never;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1777014669902
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
||||
@@ -13,33 +13,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2.1.5
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/node_modules
|
||||
key: cache-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Install and build
|
||||
run: |
|
||||
yarn install
|
||||
# pass --base if CNAME is not used
|
||||
# npm run build -- --base=/${{ github.event.repository.name }}/
|
||||
yarn build
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# Enable Vue Router history mode with 404.html hack for Github Pages
|
||||
cd dist
|
||||
ln -s index.html 404.html
|
||||
- name: Deploy to github pages
|
||||
uses: JamesIves/github-pages-deploy-action@4.1.0
|
||||
uses: JamesIves/github-pages-deploy-action@v4
|
||||
with:
|
||||
branch: gh-pages
|
||||
folder: dist
|
||||
|
||||
- name: Publish deployed code
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: published
|
||||
path: dist
|
||||
|
||||
Binary file not shown.
Vendored
+894
File diff suppressed because one or more lines are too long
@@ -0,0 +1,42 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import vue from '@astrojs/vue';
|
||||
import UnoCSS from 'unocss/astro';
|
||||
import path from 'path';
|
||||
|
||||
const src = path.resolve(import.meta.dirname, 'src');
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
vue({
|
||||
jsx: true,
|
||||
// Astro's Vue integration handles the bundler/runtime choice usually.
|
||||
// But if we need the full compiler for some components:
|
||||
// appEntrypoint: 'src/_app.ts' // We might need this for global setups
|
||||
}),
|
||||
UnoCSS({
|
||||
injectReset: true,
|
||||
})
|
||||
],
|
||||
vite: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': src,
|
||||
},
|
||||
dedupe: ['vue']
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['tg-blog']
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ['tg-blog']
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
sass: {
|
||||
api: 'modern-compiler'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
+32
-30
@@ -3,40 +3,42 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"serve": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build"
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@astrojs/check": "^0.9.8",
|
||||
"@astrojs/vue": "^6.0.1",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@unocss/astro": "^66.6.8",
|
||||
"animsition": "^4.0.2",
|
||||
"core-js": "^3.29.0",
|
||||
"emoji-regex": "^10.2.1",
|
||||
"linkify-urls": "^4.0.0",
|
||||
"marked": "^4.2.12",
|
||||
"meshline": "^3.1.6",
|
||||
"moment": "^2.29.4",
|
||||
"tg-blog": "^1.1.0",
|
||||
"three": "^0.150.1",
|
||||
"vue": "^3.2.47",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.1.6",
|
||||
"vue3-colorpicker": "^2.1.2"
|
||||
"astro": "^6.1.9",
|
||||
"core-js": "^3.49.0",
|
||||
"emoji-regex": "^10.3.0",
|
||||
"linkify-urls": "^5.2.0",
|
||||
"lxgw-wenkai-webfont": "^1.7.0",
|
||||
"marked": "^18.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"tg-blog": "^1.1.10",
|
||||
"vue": "^3.5.33",
|
||||
"vue-i18n": "^11.4.0",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jquery": "^3.5.16",
|
||||
"@types/jqueryui": "^1.12.16",
|
||||
"@types/marked": "^4.0.7",
|
||||
"@types/node": "^18.14.5",
|
||||
"@types/three": "^0.149.0",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"eslint": "^8.35.0",
|
||||
"sass": "^1.58.3",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.1.4",
|
||||
"vue-class-component": "^8.0.0-rc.1",
|
||||
"vue-property-decorator": "^10.0.0-rc.3",
|
||||
"vue-tsc": "^1.0.24"
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jqueryui": "^1.12.23",
|
||||
"@types/marked": "^6.0.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"eslint": "^10.2.1",
|
||||
"sass": "^1.99.0",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^6.0.3",
|
||||
"unocss": "^66.6.8",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
+85
-80
@@ -1,21 +1,22 @@
|
||||
<template>
|
||||
<div id="nav" class="fbox-v"
|
||||
:class="(currentRoute) + ' ' + (menuOpen ? 'open' : '')"
|
||||
v-if="currentRoute !== 'colorpicker'">
|
||||
v-if="currentRoute !== 'colorpicker'">
|
||||
<div id="menu" @click="showMenu"><i class="fas fa-bars"></i></div>
|
||||
|
||||
<div id="items" class="fbox-v">
|
||||
<router-link class="router-link" ref="others" to="/others">{{ $t('nav.others') }}</router-link>
|
||||
<router-link class="router-link" :ref="setNavRef('others')" to="/others">{{ $t('nav.others') }}</router-link>
|
||||
<div class="dot">·</div>
|
||||
<router-link class="router-link" ref="blog" to="/blog">{{ $t('nav.blog') }}</router-link>
|
||||
<router-link class="router-link" :ref="setNavRef('photo')" to="/photo">{{ $t('nav.photo') }}</router-link>
|
||||
<div class="dot">·</div>
|
||||
<router-link class="router-link" ref="life" to="/life">{{ $t('nav.life') }}</router-link>
|
||||
<router-link class="router-link" :ref="setNavRef('blog')" to="/blog">{{ $t('nav.blog') }}</router-link>
|
||||
<div class="dot">·</div>
|
||||
<router-link class="router-link" ref="about" to="/about">{{ $t('nav.about') }}</router-link>
|
||||
<router-link class="router-link" :ref="setNavRef('life')" to="/life">{{ $t('nav.life') }}</router-link>
|
||||
<div class="dot">·</div>
|
||||
<router-link class="router-link" ref="home" to="/">
|
||||
<svg focusable="false" data-prefix="fal" data-icon="house-night" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" class="svg-inline--fa fa-house-night fa-w-20"><path fill="currentColor" d="M112,224a111.5,111.5,0,0,0,87-41.45,20.51,20.51,0,0,0-19.75-33.08A59.2,59.2,0,0,1,138.84,39.85a20.3,20.3,0,0,0,10.07-21.27,20.26,20.26,0,0,0-16.47-16.7A136,136,0,0,0,112,0a112,112,0,0,0,0,224ZM97.78,33.27a91.21,91.21,0,0,0,54.47,147.9A80,80,0,1,1,97.78,33.27Zm97.15,35.51,39.72,16.56,16.56,39.72a5.33,5.33,0,0,0,9.55,0l16.56-39.72L317,68.78a5.33,5.33,0,0,0,0-9.54L277.32,42.68,260.76,3a5.33,5.33,0,0,0-9.55,0L234.65,42.68,194.93,59.24a5.34,5.34,0,0,0,0,9.54ZM157,379.24l-39.72-16.57L100.76,323a5.34,5.34,0,0,0-9.55,0L74.65,362.67,34.93,379.24a5.34,5.34,0,0,0,0,9.54l39.72,16.56,16.56,39.72a5.33,5.33,0,0,0,9.55,0l16.56-39.72L157,388.78a5.33,5.33,0,0,0,0-9.54Zm179-101.9v85.33A21.39,21.39,0,0,0,357.36,384h85.31A21.39,21.39,0,0,0,464,362.67V277.34A21.4,21.4,0,0,0,442.67,256H357.36A21.4,21.4,0,0,0,336,277.34ZM368,288H432v64H368Zm266.49,8L576,244.75V144a16,16,0,0,0-32,0v72.75L410.53,100a16,16,0,0,0-21.07,0l-224,196a16,16,0,0,0,21.07,24.09L224,287.28V464a48.05,48.05,0,0,0,48,48H528a48.06,48.06,0,0,0,48-48V287.28l37.46,32.78A16,16,0,0,0,634.53,296ZM544,464a16,16,0,0,1-16,16H272a16,16,0,0,1-16-16V264a15.94,15.94,0,0,0-.81-4L400,133.27l144,126Z" class=""></path></svg></router-link>
|
||||
<router-link class="router-link" :ref="setNavRef('about')" to="/about">{{ $t('nav.about') }}</router-link>
|
||||
<div class="dot">·</div>
|
||||
<router-link class="router-link" :ref="setNavRef('home')" to="/">
|
||||
<svg focusable="false" data-prefix="fal" data-icon="house-night" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" class="svg-inline--fa fa-house-night fa-w-20"><path fill="currentColor" d="M112,224a111.5,111.5,0,0,0,87-41.45,20.51,20.51,0,0,0-19.75-33.08A59.2,59.2,0,0,1,138.84,39.85a20.3,20.3,0,0,0,10.07-21.27,20.26,20.26,0,0,0-16.47-16.7A136,136,0,0,0,112,0a112,112,0,0,0,0,224ZM97.78,33.27a91.21,91.21,0,0,0,54.47,147.9A80,80,0,1,1,97.78,33.27Zm97.15,35.51,39.72,16.56,16.56,39.72a5.33,5.33,0,0,0,9.55,0l16.56-39.72L317,68.78a5.33,5.33,0,0,0,0-9.54L277.32,42.68,260.76,3a5.33,5.33,0,0,0-9.55,0L234.65,42.68,194.93,59.24a5.34,5.34,0,0,0,0,9.54ZM157,379.24l-39.72-16.57L100.76,323a5.34,5.34,0,0,0-9.55,0L74.65,362.67,34.93,379.24a5.34,5.34,0,0,0,0,9.54l39.72,16.56,16.56,39.72a5.33,5.33,0,0,0,9.55,0l16.56-39.72L157,388.78a5.33,5.33,0,0,0,0-9.54Zm179-101.9v85.33A21.39,21.39,0,0,0,357.36,384h85.31A21.39,21.39,0,0,0,464,362.67V277.34A21.4,21.4,0,0,0,442.67,256H357.36A21.4,21.4,0,0,0,336,277.34ZM368,288H432v64H368Zm266.49,8L576,244.75V144a16,16,0,0,0-32,0v72.75L410.53,100a16,16,0,0,0-21.07,0l-224,196a16,16,0,0,0,21.07,24.09L224,287.28V464a48.05,48.05,0,0,0,48,48H528a48.06,48.06,0,0,0,48-48V287.28l37.46,32.78A16,16,0,0,0,634.53,296ZM544,464a16,16,0,0,1-16,16H272a16,16,0,0,1-16-16V264a15.94,15.94,0,0,0-.81-4L400,133.27l144,126Z" class=""></path></svg></router-link>
|
||||
</div>
|
||||
|
||||
<div id="nav-bookmark" ref="bookmark" :style="bookmarkCss"></div>
|
||||
@@ -27,89 +28,93 @@
|
||||
<router-view/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from "vue-class-component";
|
||||
<script setup lang="ts">
|
||||
import {nextTick, onMounted, onUnmounted, ref, ComponentPublicInstance} from 'vue';
|
||||
import router from "@/scripts/router";
|
||||
import {RouteLocationNormalized} from "vue-router";
|
||||
import {RouteLocationNormalized, RouteLocationRaw, useRoute} from "vue-router";
|
||||
|
||||
@Options({components: {}})
|
||||
export default class App extends Vue
|
||||
{
|
||||
currentRoute = ''
|
||||
currentLink: Element = null as never as Element
|
||||
bookmarkCss = ""
|
||||
bookmarkUpdateIntervalId!: number
|
||||
lastTop = 0
|
||||
const route = useRoute()
|
||||
|
||||
menuOpen = false
|
||||
const currentRoute = ref('')
|
||||
const bookmarkCss = ref('')
|
||||
const lastTop = ref(0)
|
||||
const menuOpen = ref(false)
|
||||
const bookmarkUpdateIntervalId = ref<number | null>(null)
|
||||
const removeAfterEach = ref<(() => void) | null>(null)
|
||||
|
||||
showMenu(): void
|
||||
{
|
||||
this.menuOpen = !this.menuOpen
|
||||
const navRefs = ref<Record<string, Element | ComponentPublicInstance | null>>({})
|
||||
|
||||
// Auto close
|
||||
if (this.menuOpen) setTimeout(() => this.menuOpen = false, 2000)
|
||||
}
|
||||
const showMenu = (): void => {
|
||||
menuOpen.value = !menuOpen.value
|
||||
|
||||
updateBookmark(to: RouteLocationNormalized): void
|
||||
{
|
||||
// Update title
|
||||
// Use next tick to handle router history correctly
|
||||
// see: https://github.com/vuejs/vue-router/issues/914#issuecomment-384477609
|
||||
this.$nextTick(() => {
|
||||
if (to.name == 'Blog' && Object.keys(to.query).length != 0) return
|
||||
document.title = to.meta.title ? `Hykilpikonna - ${to.meta.title}` : 'Hykilpikonna - Home';
|
||||
})
|
||||
|
||||
console.log('AfterEach called', to)
|
||||
this.currentRoute = ((to.meta?.navBookmark ?? to.name) as string).toLowerCase()
|
||||
|
||||
this.calculateBookmarkCss()
|
||||
this.menuOpen = false
|
||||
}
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
console.log('Mounted called', this.$route)
|
||||
router.afterEach(this.updateBookmark)
|
||||
if (this.$route.name) this.currentRoute = ((this.$route.meta?.navBookmark ?? this.$route.name) as string).toLowerCase()
|
||||
|
||||
// Resize listener
|
||||
window.addEventListener('resize', this.calculateBookmarkCss, true);
|
||||
|
||||
// Update every second
|
||||
this.bookmarkUpdateIntervalId = window.setInterval(this.calculateBookmarkCss, 1000)
|
||||
}
|
||||
|
||||
unmounted(): void
|
||||
{
|
||||
window.removeEventListener('resize', this.calculateBookmarkCss)
|
||||
window.clearInterval(this.bookmarkUpdateIntervalId)
|
||||
}
|
||||
|
||||
calculateBookmarkCss(): void
|
||||
{
|
||||
if (this.currentRoute in this.$refs)
|
||||
this.currentLink = (this.$refs[this.currentRoute] as Vue).$el
|
||||
else return
|
||||
|
||||
// https://developer.mozilla.org/zh-CN/docs/Web/API/Element/getBoundingClientRect
|
||||
let box = this.currentLink.getBoundingClientRect()
|
||||
if (box.top == this.lastTop) return
|
||||
this.lastTop = box.top
|
||||
|
||||
let h = box.bottom - box.top
|
||||
let width = Math.round(h / 2) + 8
|
||||
|
||||
this.bookmarkCss = `top: ${box.top - 8}px;` +
|
||||
`border-width: ${width}px 20px ${width}px 50px;`
|
||||
}
|
||||
if (menuOpen.value) setTimeout(() => menuOpen.value = false, 2000)
|
||||
}
|
||||
|
||||
const setNavRef = (name: string) => (el: Element | ComponentPublicInstance | null) => {
|
||||
navRefs.value[name] = el
|
||||
}
|
||||
|
||||
const resolveNavElement = (target: Element | ComponentPublicInstance | null | undefined): Element | null => {
|
||||
if (!target) return null
|
||||
if (target instanceof Element) return target
|
||||
return (target.$el as Element | undefined) ?? null
|
||||
}
|
||||
|
||||
const calculateBookmarkCss = (): void => {
|
||||
const currentLink = resolveNavElement(navRefs.value[currentRoute.value])
|
||||
if (!currentLink) return
|
||||
|
||||
const box = currentLink.getBoundingClientRect()
|
||||
if (box.top === lastTop.value) return
|
||||
lastTop.value = box.top
|
||||
|
||||
const h = box.bottom - box.top
|
||||
const width = Math.round(h / 2) + 8
|
||||
|
||||
bookmarkCss.value = `top: ${box.top - 8}px;` +
|
||||
`border-width: ${width}px 20px ${width}px 50px;`
|
||||
}
|
||||
|
||||
const getRouteBookmark = (to: RouteLocationNormalized): string => {
|
||||
return ((to.meta?.navBookmark ?? to.name) as string).toLowerCase()
|
||||
}
|
||||
|
||||
const updateBookmark = (to: RouteLocationNormalized): void => {
|
||||
nextTick(() => {
|
||||
if (to.name == 'Blog' && Object.keys(to.query).length != 0) return
|
||||
document.title = to.meta.title ? `Aza - ${to.meta.title}` : 'Aza - Home';
|
||||
})
|
||||
|
||||
console.log('AfterEach called', to)
|
||||
currentRoute.value = getRouteBookmark(to)
|
||||
|
||||
calculateBookmarkCss()
|
||||
menuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('Mounted called', route)
|
||||
removeAfterEach.value = router.afterEach(updateBookmark)
|
||||
if (route.name) {
|
||||
currentRoute.value = getRouteBookmark(route as unknown as RouteLocationNormalized)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', calculateBookmarkCss, true)
|
||||
bookmarkUpdateIntervalId.value = window.setInterval(calculateBookmarkCss, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
removeAfterEach.value?.()
|
||||
window.removeEventListener('resize', calculateBookmarkCss, true)
|
||||
if (bookmarkUpdateIntervalId.value !== null) {
|
||||
window.clearInterval(bookmarkUpdateIntervalId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
@import "css/global"
|
||||
@import "css/animations"
|
||||
@use "css/global"
|
||||
@use "css/animations"
|
||||
|
||||
#nav
|
||||
position: fixed
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import {Color} from "three";
|
||||
|
||||
/**
|
||||
* Configurations
|
||||
*/
|
||||
export const config = {
|
||||
|
||||
// Field of vision and cutoff frustum for near and far
|
||||
cam: {fov: 50, near: 1, far: 5000},
|
||||
|
||||
// Smooth movements (speeds are in terms of pixels per ms)
|
||||
smooth: {mouseSpeed: 10 * window.devicePixelRatio},
|
||||
|
||||
// Cursor
|
||||
cursor: {radius: 2, width: 0.3, color: new Color('#333')},
|
||||
|
||||
// Debug mode
|
||||
debug: false,
|
||||
|
||||
// Edit mode
|
||||
editMode: true,
|
||||
|
||||
// Editor config
|
||||
editor: {zMin: 70, zMax: 90}
|
||||
}
|
||||
|
||||
export const colors = {
|
||||
sky: {
|
||||
top: new Color('#3284ff'),
|
||||
bottom: new Color('#ffffff'),
|
||||
ground: new Color('#ffc87f'),
|
||||
dirLight: new Color('#fff4e5')
|
||||
},
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Create a 3D box geometry made out of dashed lines
|
||||
* @param width
|
||||
* @param height
|
||||
* @param depth
|
||||
*/
|
||||
export function box(width: number, height: number, depth: number): THREE.BufferGeometry
|
||||
{
|
||||
width = width * 0.5
|
||||
height = height * 0.5
|
||||
depth = depth * 0.5
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const position = [];
|
||||
|
||||
for (const x of [-1, 1])
|
||||
for (const y of [-1, 1])
|
||||
for (const z of [-1, 1])
|
||||
{
|
||||
const rx = x * width, ry = y * height, rz = z * depth
|
||||
position.push(rx, ry, rz)
|
||||
position.push(rx * -x, ry, rz)
|
||||
position.push(rx, ry, rz)
|
||||
position.push(rx, ry * -y, rz)
|
||||
position.push(rx, ry, rz)
|
||||
position.push(rx, ry, rz * -z)
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(position, 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 2D circle
|
||||
* @param color
|
||||
* @param z
|
||||
* @param r
|
||||
* @param hollow
|
||||
*/
|
||||
export function circle(color: THREE.Color | number | string, z: number, r: number): THREE.Mesh
|
||||
{
|
||||
const geometry = new THREE.CircleGeometry(r, 64)
|
||||
const material = new THREE.MeshBasicMaterial({color})
|
||||
const circle = new THREE.Mesh(geometry, material)
|
||||
circle.position.z = z
|
||||
return circle
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore depth https://stackoverflow.com/a/62818553/7346633
|
||||
* @param obj
|
||||
* @param material
|
||||
*/
|
||||
export function alwaysOnTop(obj: THREE.Object3D, material: THREE.Material): void
|
||||
{
|
||||
obj.renderOrder = 999
|
||||
material.depthTest = false
|
||||
material.depthWrite = false
|
||||
obj.onBeforeRender = (r) => r.clearDepth()
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import {Color} from 'three'
|
||||
import * as helper from "@/animation/Helpers"
|
||||
import {initMouseTracker} from "@/animation/Trackers"
|
||||
import {addDirLight, addGround, addHemiLight, addSky} from "@/animation/Shaders"
|
||||
import {config} from "@/animation/Config"
|
||||
import Cursor from "@/animation/components/Cursor";
|
||||
import IUpdatable from "@/animation/components/IUpdatable";
|
||||
import Grid from "@/animation/components/Grid";
|
||||
import Editor from "@/animation/components/Editor";
|
||||
|
||||
export let renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.PerspectiveCamera
|
||||
export let editor: Editor
|
||||
const clock = new THREE.Clock()
|
||||
export const objects: { [id: string]: THREE.Object3D } = {}
|
||||
const updatable: IUpdatable[] = []
|
||||
|
||||
// ////////////////////
|
||||
// Three.js scene code
|
||||
|
||||
function init(): void
|
||||
{
|
||||
const geometryBox = helper.box(50, 50, 1000)
|
||||
|
||||
const lineSegments = new THREE.LineSegments(geometryBox, new THREE.LineDashedMaterial({
|
||||
color: "#420000",
|
||||
dashSize: 3,
|
||||
gapSize: 1
|
||||
}))
|
||||
lineSegments.computeLineDistances()
|
||||
|
||||
updatable.push(new Cursor(scene, config.cursor, camera))
|
||||
updatable.push(new Grid(), editor = new Editor())
|
||||
|
||||
objects.box = lineSegments
|
||||
scene.add(lineSegments)
|
||||
|
||||
// scene.add(circle(0xffff00, 0, 5))
|
||||
// scene.add(circle(0xff00ff, 1, 4))
|
||||
// scene.add(circle(0x0000ff, 2, 3))
|
||||
// scene.add(circle(0x00ffff, 3, 2))
|
||||
// scene.add(circle(0xff0000, 4, 1))
|
||||
}
|
||||
|
||||
// Buffer for smooth update
|
||||
const smoothBuffer = {cam: {x: 0, y: 0}}
|
||||
|
||||
function pn(b: boolean): number
|
||||
{
|
||||
return b ? 1 : -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frame
|
||||
*
|
||||
* @param dt delta time in ms
|
||||
*/
|
||||
function update(dt: number): void
|
||||
{
|
||||
// objects['cursor'].position.set(moused.x, moused.y, 150)
|
||||
|
||||
// smoothBuffer.cam.x = moused.x * config.mouseFactor
|
||||
// smoothBuffer.cam.y = moused.y * config.mouseFactor
|
||||
// smoothUpdate()
|
||||
|
||||
// const time = Date.now() * 0.001
|
||||
// objects.box.rotation.x = 0.25 * time
|
||||
// objects.box.rotation.y = 0.25 * time
|
||||
|
||||
function smoothUpdate(): void
|
||||
{
|
||||
// Pixels moved = speed * time
|
||||
const delta = config.smooth.mouseSpeed * dt
|
||||
// Current position
|
||||
const cp = camera.position
|
||||
// Target position
|
||||
const tp = smoothBuffer.cam
|
||||
|
||||
if (Math.abs(cp.x - tp.x) > delta)
|
||||
{
|
||||
cp.x = cp.x + delta * pn(cp.x < tp.x)
|
||||
} else
|
||||
{
|
||||
cp.x = tp.x
|
||||
}
|
||||
|
||||
if (Math.abs(cp.y - tp.y) > delta)
|
||||
{
|
||||
cp.y = cp.y + delta * pn(cp.y < tp.y)
|
||||
} else
|
||||
{
|
||||
cp.y = tp.y
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ///////////////////
|
||||
// Three.js meta code
|
||||
|
||||
/**
|
||||
* Start the three.js rendering engine
|
||||
*
|
||||
* @param id: Canvas element id
|
||||
*/
|
||||
export function start(id: string): void
|
||||
{
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new Color('#f9f2e0')
|
||||
// Create camera
|
||||
camera = new THREE.PerspectiveCamera(config.cam.fov, window.innerWidth / window.innerHeight,
|
||||
config.cam.near, config.cam.far)
|
||||
camera.position.set(0, 0, 200)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
// @ts-ignore Create WebGL Renderer
|
||||
renderer = new THREE.WebGLRenderer({canvas: document.getElementById(id), antialias: true})
|
||||
onWindowResize()
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
|
||||
addLights()
|
||||
|
||||
init()
|
||||
initMouseTracker()
|
||||
animate()
|
||||
}
|
||||
|
||||
function addLights(): void
|
||||
{
|
||||
objects.hemiLight = addHemiLight(scene)
|
||||
objects.dirLight = addDirLight(scene)
|
||||
objects.ground = addGround(scene)
|
||||
objects.sky = addSky(scene)
|
||||
objects.sky.visible = false
|
||||
objects.ground.visible = false
|
||||
|
||||
renderer.outputEncoding = THREE.sRGBEncoding
|
||||
renderer.shadowMap.enabled = true
|
||||
}
|
||||
|
||||
function onWindowResize()
|
||||
{
|
||||
camera.aspect = window.innerWidth / window.innerHeight
|
||||
camera.updateProjectionMatrix()
|
||||
|
||||
renderer.setPixelRatio(window.devicePixelRatio)
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
}
|
||||
|
||||
function animate(): void
|
||||
{
|
||||
requestAnimationFrame(animate)
|
||||
const dt = clock.getDelta()
|
||||
update(dt)
|
||||
for (const o of updatable) o.update(dt)
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import {DirectionalLight, HemisphereLight, Mesh} from 'three'
|
||||
import {colors, config} from "@/animation/Config";
|
||||
|
||||
export const vertexShader = `
|
||||
varying vec3 vWorldPosition;
|
||||
|
||||
void main() {
|
||||
vec4 worldPosition = modelMatrix * vec4( position, 1.0 );
|
||||
vWorldPosition = worldPosition.xyz;
|
||||
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
|
||||
}`;
|
||||
|
||||
export const fragmentShader = `
|
||||
uniform vec3 topColor;
|
||||
uniform vec3 bottomColor;
|
||||
uniform float offset;
|
||||
uniform float exponent;
|
||||
|
||||
varying vec3 vWorldPosition;
|
||||
|
||||
void main() {
|
||||
float h = normalize( vWorldPosition + offset ).y;
|
||||
gl_FragColor = vec4( mix( bottomColor, topColor, max( pow( max( h , 0.0), exponent ), 0.0 ) ), 1.0 );
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Create sky dome
|
||||
* @param scene
|
||||
*/
|
||||
export function addSky(scene: THREE.Scene): Mesh
|
||||
{
|
||||
const uniforms = {
|
||||
"topColor": { value: colors.sky.top },
|
||||
"bottomColor": { value: colors.sky.bottom },
|
||||
"offset": { value: 33 },
|
||||
"exponent": { value: 0.6 }
|
||||
}
|
||||
|
||||
const skyGeo = new THREE.SphereGeometry( 4000, 32, 15 );
|
||||
const skyMat = new THREE.ShaderMaterial({
|
||||
uniforms: uniforms,
|
||||
vertexShader: vertexShader,
|
||||
fragmentShader: fragmentShader,
|
||||
side: THREE.BackSide
|
||||
});
|
||||
|
||||
const sky = new THREE.Mesh(skyGeo, skyMat)
|
||||
scene.add(sky)
|
||||
scene.fog = new THREE.Fog(colors.sky.bottom, 1, 5000)
|
||||
|
||||
return sky
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ground
|
||||
* @param scene
|
||||
*/
|
||||
export function addGround(scene: THREE.Scene): Mesh
|
||||
{
|
||||
const groundGeo = new THREE.PlaneGeometry(10000, 10000)
|
||||
const groundMat = new THREE.MeshLambertMaterial({color: colors.sky.ground})
|
||||
const ground = new THREE.Mesh(groundGeo, groundMat)
|
||||
ground.position.y = -33
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.receiveShadow = true
|
||||
scene.add(ground)
|
||||
|
||||
return ground
|
||||
}
|
||||
|
||||
/**
|
||||
* Add hemisphere light
|
||||
* @param scene
|
||||
*/
|
||||
export function addHemiLight(scene: THREE.Scene): HemisphereLight
|
||||
{
|
||||
const hemiLight = new THREE.HemisphereLight(colors.sky.top, colors.sky.ground, 0.6)
|
||||
hemiLight.position.set(0, 50, 0)
|
||||
scene.add(hemiLight)
|
||||
|
||||
if (config.debug) scene.add(new THREE.HemisphereLightHelper(hemiLight, 10))
|
||||
|
||||
return hemiLight
|
||||
}
|
||||
|
||||
/**
|
||||
* Add directional light
|
||||
* @param scene
|
||||
*/
|
||||
export function addDirLight(scene: THREE.Scene): DirectionalLight
|
||||
{
|
||||
// Directional light
|
||||
const dirLight = new THREE.DirectionalLight(colors.sky.dirLight, 1)
|
||||
dirLight.position.set(-1, 1.75, 1)
|
||||
dirLight.position.multiplyScalar(30)
|
||||
scene.add(dirLight)
|
||||
|
||||
dirLight.castShadow = true
|
||||
|
||||
dirLight.shadow.mapSize.width = 2048
|
||||
dirLight.shadow.mapSize.height = 2048
|
||||
|
||||
const d = 50
|
||||
|
||||
dirLight.shadow.camera.left = -d
|
||||
dirLight.shadow.camera.right = d
|
||||
dirLight.shadow.camera.top = d
|
||||
dirLight.shadow.camera.bottom = -d
|
||||
|
||||
dirLight.shadow.camera.far = 3500
|
||||
dirLight.shadow.bias = -0.0001
|
||||
|
||||
if (config.debug) scene.add(new THREE.DirectionalLightHelper(dirLight, 10))
|
||||
|
||||
return dirLight
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import {Vector3} from 'three'
|
||||
import {camera} from "@/animation/Home";
|
||||
|
||||
export let mouse: MouseEvent
|
||||
export const moused = {x: 0, y: 0, pos: new Vector3()}
|
||||
|
||||
/**
|
||||
* Initialize mouse tracker
|
||||
*/
|
||||
export function initMouseTracker(): void
|
||||
{
|
||||
document.onmousemove = (e: MouseEvent) =>
|
||||
{
|
||||
mouse = e
|
||||
moused.x = e.clientX / window.innerWidth * 2 - 1
|
||||
moused.y = -(e.clientY / window.innerHeight * 2 - 1)
|
||||
moused.pos = projectTo3D(moused.x, moused.y, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project to 3D position
|
||||
* https://www.reddit.com/r/threejs/comments/eba9l3/3d_cursor_using_threejs_html_css/
|
||||
* https://jsfiddle.net/atwfxdpd/10/
|
||||
*
|
||||
* @param screenX X position on 2D canvas
|
||||
* @param screenY Y position on 2D canvas
|
||||
* @param z Z position in 3D
|
||||
*/
|
||||
export function projectTo3D(screenX: number, screenY: number, z: number): Vector3
|
||||
{
|
||||
const vector = new THREE.Vector3(screenX, screenY, 0.5)
|
||||
vector.unproject(camera)
|
||||
const dir = vector.sub(camera.position).normalize()
|
||||
const distance = (-camera.position.z + z) / dir.z
|
||||
const pos = camera.position.clone().add(dir.multiplyScalar(distance))
|
||||
// console.log('Dir:', dir)
|
||||
// console.log('Pos:', pos)
|
||||
return pos
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import {Color, Material} from "three";
|
||||
import {MeshLineGeometry, MeshLineMaterial} from 'meshline';
|
||||
import IUpdatable from "@/animation/components/IUpdatable";
|
||||
import {moused} from "@/animation/Trackers";
|
||||
import {alwaysOnTop, circle} from "@/animation/Helpers";
|
||||
|
||||
type CursorConfig = {radius: number, color: Color, width: number}
|
||||
|
||||
export default class Cursor implements IUpdatable
|
||||
{
|
||||
camera: THREE.Camera
|
||||
circle: CursorCircle
|
||||
dot: THREE.Mesh
|
||||
|
||||
constructor(scene: THREE.Scene, conf: CursorConfig, camera: THREE.Camera)
|
||||
{
|
||||
this.camera = camera
|
||||
this.circle = new CursorCircle(conf, camera)
|
||||
scene.add(this.circle)
|
||||
|
||||
this.dot = circle('#000', 0, 0.3)
|
||||
alwaysOnTop(this.dot, this.dot.material as Material)
|
||||
scene.add(this.dot)
|
||||
|
||||
this.circle.visible = false
|
||||
}
|
||||
|
||||
update(dt: number): void
|
||||
{
|
||||
this.circle.update(dt)
|
||||
this.dot.position.copy(moused.pos)
|
||||
this.circle.position.copy(moused.pos)
|
||||
}
|
||||
}
|
||||
|
||||
export class CursorCircle extends THREE.Mesh implements IUpdatable
|
||||
{
|
||||
camera: THREE.Camera
|
||||
|
||||
constructor(conf: CursorConfig, camera: THREE.Camera)
|
||||
{
|
||||
// https://discourse.threejs.org/t/shift-vertices-of-circle-geometry-not-working/26664
|
||||
const pts = new THREE.Path().absarc(0, 0, conf.radius, 0, Math.PI * 2, true).getPoints(90)
|
||||
const geometry = new MeshLineGeometry()
|
||||
geometry.setFromPoints(pts)
|
||||
|
||||
// MeshLine: https://stackoverflow.com/a/25759280/7346633
|
||||
const material = new MeshLineMaterial({color: conf.color, lineWidth: conf.width,
|
||||
resolution: new THREE.Vector2(window.innerWidth, window.innerHeight)})
|
||||
super(geometry, material)
|
||||
|
||||
this.camera = camera
|
||||
|
||||
alwaysOnTop(this, material)
|
||||
}
|
||||
|
||||
update(dt: number): void
|
||||
{
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import {Mesh, MeshBasicMaterial, Vector3} from "three";
|
||||
import IUpdatable from "@/animation/components/IUpdatable";
|
||||
import {circle} from "@/animation/Helpers";
|
||||
import {scene} from "@/animation/Home";
|
||||
import {moused, projectTo3D} from "@/animation/Trackers";
|
||||
import {config} from "@/animation/Config";
|
||||
import {minMax} from "@/scripts/utils";
|
||||
|
||||
type DisplayCircle = {x: number, y: number, z: number, radius: number, color: string}
|
||||
|
||||
export default class Editor implements IUpdatable
|
||||
{
|
||||
hand: Mesh
|
||||
radius = 3
|
||||
scale = 1
|
||||
z = config.editor.zMax
|
||||
data: DisplayCircle[] = []
|
||||
circles: Mesh[] = []
|
||||
|
||||
constructor()
|
||||
{
|
||||
this.hand = circle('#ffffff', 0, this.radius)
|
||||
scene.add(this.hand)
|
||||
|
||||
window.addEventListener('mousedown', (e) =>
|
||||
{
|
||||
console.log('clicked', e)
|
||||
this.addCirc(this.radius * this.scale, this.hand.position, this.color)
|
||||
})
|
||||
|
||||
window.addEventListener('wheel', (e) =>
|
||||
{
|
||||
let direction = (e.detail < 0 || e.deltaY > 0) ? 1 : -1;
|
||||
|
||||
// Shift to micro-adjust
|
||||
if (e.shiftKey) direction /= 10
|
||||
|
||||
// Ctrl + Alt to shift the entire plane
|
||||
if (e.altKey && e.ctrlKey)
|
||||
{
|
||||
// TODO
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll to adjust z, alt + scroll to adjust radius
|
||||
if (e.altKey)
|
||||
{
|
||||
this.scale -= direction / 10
|
||||
this.hand.scale.set(this.scale, this.scale, this.scale)
|
||||
}
|
||||
else this.z = minMax(this.z - direction * 2, config.editor.zMin, config.editor.zMax)
|
||||
|
||||
}, false)
|
||||
}
|
||||
|
||||
addCirc(radius: number, pos: Vector3, color: string): void
|
||||
{
|
||||
const data = {...pos, radius, color}
|
||||
data.z -= config.editor.zMax
|
||||
this.data.push(data)
|
||||
this.displayCirc(data)
|
||||
}
|
||||
|
||||
displayCirc(params: DisplayCircle): void
|
||||
{
|
||||
const circ = circle(params.color, 0, params.radius)
|
||||
circ.position.x = params.x
|
||||
circ.position.y = params.y
|
||||
circ.position.z = params.z + config.editor.zMax
|
||||
scene.add(circ)
|
||||
this.circles.push(circ)
|
||||
}
|
||||
|
||||
update(dt: number): void
|
||||
{
|
||||
const pos = projectTo3D(moused.x, moused.y, this.z)
|
||||
this.hand.position.copy(pos)
|
||||
return
|
||||
}
|
||||
|
||||
get color(): string
|
||||
{
|
||||
return '#' + this.handMaterial.color.getHexString()
|
||||
}
|
||||
|
||||
set color(value: string)
|
||||
{
|
||||
this.handMaterial.color.setStyle(value.substr(0, 7))
|
||||
}
|
||||
|
||||
get handMaterial(): MeshBasicMaterial { return (this.hand.material as MeshBasicMaterial) }
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as THREE from "three";
|
||||
import {GridHelper} from "three";
|
||||
import {scene} from "@/animation/Home";
|
||||
import IUpdatable from "@/animation/components/IUpdatable";
|
||||
|
||||
export default class Grid implements IUpdatable
|
||||
{
|
||||
lines = []
|
||||
grid: GridHelper[] = []
|
||||
|
||||
constructor()
|
||||
{
|
||||
const size = 100
|
||||
const divisions = 20
|
||||
|
||||
let color = new THREE.Color('#7a0c0c')
|
||||
let grid = new THREE.GridHelper(size, divisions, color, color)
|
||||
grid.rotation.x = Math.PI / 2
|
||||
grid.position.z = 90
|
||||
this.grid.push(grid)
|
||||
|
||||
color = new THREE.Color('#a4a4a4')
|
||||
grid = new THREE.GridHelper(size, divisions, color, color)
|
||||
grid.rotation.x = Math.PI / 2
|
||||
grid.position.z = 70
|
||||
this.grid.push(grid)
|
||||
|
||||
this.grid.forEach(it => scene.add(it))
|
||||
}
|
||||
|
||||
update(dt: number): void
|
||||
{
|
||||
// this.grid.rotation.x = 0.25 * Date.now() * 0.001
|
||||
// console.log(this.grid.rotation.x)
|
||||
// this.grid.position.z += dt * 10
|
||||
// console.log(this.grid.position.z)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default interface IUpdatable
|
||||
{
|
||||
update: (dt: number) => void
|
||||
}
|
||||
@@ -1,46 +1,36 @@
|
||||
<template>
|
||||
<div class="index index-tags" v-if="mode === 'tags'">
|
||||
<Tag v-for="t in meta.tags" :key="t" :tag-name="t[0]" direction="right"
|
||||
<Tag v-for="t in globals.staticMeta.tags" :key="`${t[0]}-${t[1]}`" :tag-name="t[0]" direction="right"
|
||||
@click="e => clickTag(e, t)">{{ t[0] }} ({{ t[1] }})</Tag>
|
||||
</div>
|
||||
<div class="index index-categories" v-else>
|
||||
<span v-for="c in meta.categories" :key="c[0]" class="clickable unselectable"
|
||||
<span v-for="c in globals.staticMeta.categories" :key="c[0]" class="clickable unselectable"
|
||||
@click="e => clickCat(e, c)">{{ c[0] }} ({{ c[1] }})</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {staticMeta} from '@/views/Blog.vue'
|
||||
<script setup lang="ts">
|
||||
import Tag from "@/components/Tag.vue";
|
||||
import {Prop} from "vue-property-decorator";
|
||||
import {pushQuery} from "@/scripts/router";
|
||||
import {BlogMeta} from "@/scripts/models";
|
||||
import {globals} from "@/scripts/global";
|
||||
|
||||
@Options({components: {Tag}})
|
||||
export default class BlogIndexLinks extends Vue
|
||||
{
|
||||
@Prop({default: 'tags'}) mode: 'tags' | 'categories' = 'tags'
|
||||
withDefaults(defineProps<{ mode?: 'tags' | 'categories' }>(), {
|
||||
mode: 'tags'
|
||||
})
|
||||
|
||||
meta: BlogMeta = staticMeta
|
||||
const clickCat = (e: MouseEvent, cat: [string, number]): void => {
|
||||
e.stopPropagation()
|
||||
pushQuery({category: cat[0], tag: null})
|
||||
}
|
||||
|
||||
clickCat(e: MouseEvent, cat: [string, number]): void
|
||||
{
|
||||
e.stopPropagation()
|
||||
pushQuery({category: cat[0], tag: null})
|
||||
}
|
||||
|
||||
|
||||
clickTag(e: MouseEvent, tag: [string, number]): void
|
||||
{
|
||||
e.stopPropagation()
|
||||
pushQuery({tag: tag[0], category: null})
|
||||
}
|
||||
const clickTag = (e: MouseEvent, tag: [string, number]): void => {
|
||||
e.stopPropagation()
|
||||
pushQuery({tag: tag[0], category: null})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import 'src/css/colors'
|
||||
@use '../css/colors'
|
||||
|
||||
.index
|
||||
*
|
||||
@@ -56,7 +46,7 @@ export default class BlogIndexLinks extends Vue
|
||||
|
||||
.index-categories
|
||||
font-size: 0.8em
|
||||
color: $color-text-special
|
||||
color: colors.$color-text-special
|
||||
|
||||
*
|
||||
text-decoration: underline
|
||||
|
||||
+79
-36
@@ -1,26 +1,25 @@
|
||||
<template>
|
||||
<div id="BlogPostPreview" class="card" :class="elClass">
|
||||
<img class="title-image" :src="image" v-if="image && imageOnTop" alt="Title Image">
|
||||
<img class="title-image" :src="p.meta.title_image" v-if="p.meta.title_image && imageOnTop" alt="Title Image">
|
||||
|
||||
<div id="titles" class="unselectable clickable" @click="clickTitle">
|
||||
<div id="date">{{ date.format('YYYY-MM-DD') }}</div>
|
||||
<div id="title">{{ meta.title }}</div>
|
||||
<div id="subtitle" v-if="meta.subtitle">{{ meta.subtitle }}</div>
|
||||
<div id="title">{{ p.meta.title }}</div>
|
||||
<div id="subtitle" v-if="p.meta.subtitle">{{ p.meta.subtitle }}</div>
|
||||
<div class="tags">
|
||||
<div v-if="tagOnTop" style="display: inline-block">
|
||||
<Tag v-for="t in meta.tags" :key="t" direction="left">{{ t }}</Tag>
|
||||
<Tag v-for="t in p.meta.tags" :key="t" direction="left">{{ t }}</Tag>
|
||||
</div>
|
||||
<i id="pin" class="fas fa-thumbtack" v-if="meta.pinned"></i>
|
||||
<i id="pin" class="fas fa-thumbtack" v-if="p.meta.pinned"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<img class="title-image" :src="image" v-if="image && !imageOnTop" alt="Title Image">
|
||||
<div id="text" class="markdown-content">
|
||||
<Dynamic :template="content"></Dynamic>
|
||||
<img class="title-image" :src="p.meta.title_image" v-if="p.meta.title_image && !imageOnTop" alt="Title Image">
|
||||
<div id="text" class="markdown-content" v-html="content" ref="textRef">
|
||||
</div>
|
||||
<div class="tags" v-if="!tagOnTop">
|
||||
<Tag v-for="t in meta.tags" :key="t[0]" direction="right">{{ t }}</Tag>
|
||||
<Tag v-for="t in p.meta.tags" :key="t[0]" direction="right">{{ t }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,8 +31,9 @@ import {BlogPost} from "@/scripts/models";
|
||||
import {pushQuery} from "@/scripts/router";
|
||||
import {$, hosts} from "@/scripts/constants";
|
||||
import {marked} from "marked";
|
||||
import moment from "moment/moment";
|
||||
import {computed, onMounted, watch} from 'vue';
|
||||
import moment from "moment";
|
||||
import {computed, onMounted, watch, ref, nextTick, createApp} from 'vue';
|
||||
import BlogIndex from './BlogIndex.vue';
|
||||
|
||||
const p = withDefaults(defineProps<{
|
||||
meta: BlogPost
|
||||
@@ -46,7 +46,8 @@ const p = withDefaults(defineProps<{
|
||||
active: false
|
||||
})
|
||||
|
||||
const uid = (Math.random() + 1).toString(36).substring(7)
|
||||
const uid = 'bp-' + p.meta.url_name.replace(/[^a-z0-9]/gi, '-')
|
||||
const textRef = ref<HTMLElement | null>(null)
|
||||
|
||||
let isActiveChangeDueToClickTitle = false
|
||||
|
||||
@@ -60,6 +61,23 @@ function clickTitle(): void
|
||||
else pushQuery({post: null})
|
||||
}
|
||||
|
||||
const mountComponents = () => {
|
||||
if (!textRef.value) return
|
||||
|
||||
// Handle BlogIndex
|
||||
textRef.value.querySelectorAll('blogindex').forEach(el => {
|
||||
const mode = el.getAttribute('mode') as any || 'tags'
|
||||
const app = createApp(BlogIndex, { mode })
|
||||
app.mount(el)
|
||||
})
|
||||
|
||||
// Fix image heights from attributes
|
||||
textRef.value.querySelectorAll('img[height]').forEach(img => {
|
||||
const h = img.getAttribute('height');
|
||||
if (h) (img as HTMLElement).style.height = h.includes('px') ? h : h + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTitle()
|
||||
|
||||
@@ -68,36 +86,26 @@ onMounted(() => {
|
||||
collapsible: true, header: '#titles', heightStyle: 'content',
|
||||
active: p.active ? 0 : false
|
||||
})
|
||||
|
||||
mountComponents()
|
||||
})
|
||||
|
||||
/**
|
||||
* Watch active status change, use this to change accordions' activation on history back/forward
|
||||
*
|
||||
* Also use this to change the title
|
||||
*/
|
||||
watch(() => p.active, (active, _) => {
|
||||
updateTitle()
|
||||
|
||||
// Ignore active status changes due to clicking the title
|
||||
console.log('Blog Post: onActiveChange Called on', p.meta.title)
|
||||
if (isActiveChangeDueToClickTitle)
|
||||
{
|
||||
isActiveChangeDueToClickTitle = false
|
||||
return
|
||||
}
|
||||
|
||||
// Change accordion activation status
|
||||
console.log('Blog Post: onActiveChange Called on', p.meta.title, 'active:', active)
|
||||
$(`.${uid}`).accordion('option', {active: active ? 0 : false});
|
||||
})
|
||||
|
||||
watch(() => p.meta.content, () => {
|
||||
nextTick(mountComponents)
|
||||
})
|
||||
|
||||
function updateTitle(): void
|
||||
{
|
||||
if (p.active) document.title = `Blog: ${p.meta.title}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Element classes
|
||||
*/
|
||||
const elClass = computed(() =>
|
||||
{
|
||||
let classes = [uid]
|
||||
@@ -106,13 +114,27 @@ const elClass = computed(() =>
|
||||
return classes
|
||||
})
|
||||
|
||||
const content = computed(() => marked(p.meta.content.replaceAll('\n', ' \n')))
|
||||
const date = moment(p.meta.date)
|
||||
const image = p.meta.title_image ? hosts.content + '/' + p.meta.title_image : null
|
||||
const content = computed(() => {
|
||||
let raw = p.meta.content.replaceAll('\n', ' \n').replaceAll("{src}", hosts.content)
|
||||
let html = marked.parse(raw) as string
|
||||
|
||||
// Handle Obsidian-style images ![[url|caption]]
|
||||
html = html.replace(/!\[\[(.*?)\]\]/g, (match, content) => {
|
||||
const [url, caption] = content.split('|')
|
||||
const fullUrl = url.startsWith('http') ? url : `${hosts.content}/posts/${url}`
|
||||
if (caption) {
|
||||
return `<figure class="image-wrap"><img src="${fullUrl}" alt="${caption}"><caption class="caption">${caption}</caption></figure>`
|
||||
}
|
||||
return `<img src="${fullUrl}">`
|
||||
})
|
||||
|
||||
return html
|
||||
})
|
||||
const date = computed(() => moment(p.meta.date))
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import 'src/css/colors'
|
||||
@use '../css/colors'
|
||||
|
||||
#BlogPostPreview
|
||||
text-align: left
|
||||
@@ -122,11 +144,32 @@ const image = p.meta.title_image ? hosts.content + '/' + p.meta.title_image : nu
|
||||
|
||||
#date
|
||||
font-size: 0.7em
|
||||
color: $color-text-light
|
||||
color: colors.$color-text-light
|
||||
|
||||
> * + *, #content > * + *
|
||||
padding-top: 10px
|
||||
|
||||
.image-wrap
|
||||
margin: 1em 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
img
|
||||
margin-left: 0
|
||||
margin-right: 0
|
||||
width: 100%
|
||||
|
||||
.caption, caption
|
||||
display: block
|
||||
width: 100%
|
||||
text-align: center
|
||||
margin-top: 0.5em
|
||||
font-size: 1.1em
|
||||
color: colors.$color-text-light
|
||||
font-family: 'Caveat', 'Shadows Into Light', cursive
|
||||
opacity: 0.8
|
||||
|
||||
.tags
|
||||
font-size: 0.7em
|
||||
z-index: 50
|
||||
@@ -151,7 +194,7 @@ const image = p.meta.title_image ? hosts.content + '/' + p.meta.title_image : nu
|
||||
|
||||
#subtitle
|
||||
font-size: 0.8em
|
||||
color: $color-text-light
|
||||
color: colors.$color-text-light
|
||||
|
||||
img
|
||||
$margin: 10px
|
||||
@@ -172,7 +215,7 @@ const image = p.meta.title_image ? hosts.content + '/' + p.meta.title_image : nu
|
||||
#expand
|
||||
font-size: 0.8em
|
||||
padding-top: 10px
|
||||
color: $color-text-light
|
||||
color: colors.$color-text-light
|
||||
|
||||
// Put image on top
|
||||
#BlogPostPreview.image-top
|
||||
|
||||
+17
-20
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="collapse">
|
||||
<div class="collapse" ref="root">
|
||||
<h3 v-html="displayTitle" class="clickable"></h3>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
@@ -7,30 +7,27 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Prop} from "vue-property-decorator";
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {$} from '@/scripts/constants';
|
||||
|
||||
@Options({components: {}})
|
||||
export default class Collapse extends Vue
|
||||
{
|
||||
@Prop() title!: string
|
||||
@Prop({default: false}) active = false
|
||||
const props = withDefaults(defineProps<{ title: string, active?: boolean }>(), {
|
||||
active: false
|
||||
})
|
||||
|
||||
show = false
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const displayTitle = computed((): string => decodeURIComponent(props.title))
|
||||
|
||||
get displayTitle(): string
|
||||
{
|
||||
return decodeURIComponent(this.title)
|
||||
onMounted((): void => {
|
||||
if (root.value) {
|
||||
$(root.value).accordion({
|
||||
collapsible: true,
|
||||
header: 'h3',
|
||||
heightStyle: 'content',
|
||||
active: props.active
|
||||
})
|
||||
}
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
$('.collapse').accordion({collapsible: true, header: 'h3', heightStyle: 'content',
|
||||
active: this.active})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h } from 'vue';
|
||||
import Collapse from './Collapse.vue';
|
||||
import Tag from './Tag.vue';
|
||||
import BlogIndex from './BlogIndex.vue';
|
||||
import ZoteroPublication from './ZoteroPublication.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Dynamic',
|
||||
props: {
|
||||
template: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
render() {
|
||||
return h({
|
||||
template: this.template,
|
||||
components: {
|
||||
Collapse,
|
||||
Tag,
|
||||
BlogIndex,
|
||||
ZoteroPublication
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -7,41 +7,33 @@
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Prop} from "vue-property-decorator";
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
@Options({components: {}})
|
||||
export default class MetaTable extends Vue
|
||||
{
|
||||
@Prop({required: true}) table!: {[id: string]: unknown}
|
||||
const props = defineProps<{ table: {[id: string]: unknown} }>()
|
||||
|
||||
get filteredTable(): {[id: string]: unknown}
|
||||
{
|
||||
const t: {[id: string]: unknown} = {}
|
||||
const filteredTable = computed((): {[id: string]: unknown} => {
|
||||
const t: {[id: string]: unknown} = {}
|
||||
|
||||
Object.keys(this.table).forEach(k => {
|
||||
// Ignore empty
|
||||
if (!this.table[k]) return
|
||||
Object.keys(props.table).forEach(k => {
|
||||
if (!props.table[k]) return
|
||||
|
||||
// Convert to sentence case (https://stackoverflow.com/a/7225450/7346633)
|
||||
let newK = k.replace(/([A-Z])/g, " $1")
|
||||
newK = newK.charAt(0).toUpperCase() + newK.slice(1)
|
||||
t[newK] = this.table[k]
|
||||
})
|
||||
let newK = k.replace(/([A-Z])/g, " $1")
|
||||
newK = newK.charAt(0).toUpperCase() + newK.slice(1)
|
||||
t[newK] = props.table[k]
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
}
|
||||
return t
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import "src/css/colors"
|
||||
@use "../css/colors"
|
||||
|
||||
.meta
|
||||
td:first-child
|
||||
text-align: right
|
||||
color: $color-text-light
|
||||
color: colors.$color-text-light
|
||||
|
||||
td:last-child
|
||||
display: inline-block
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div id="nav" class="fbox-v"
|
||||
:class="(currentRoute) + ' ' + (menuOpen ? 'open' : '')">
|
||||
<div id="menu" @click="showMenu"><i class="fas fa-bars"></i></div>
|
||||
|
||||
<div id="items" class="fbox-v">
|
||||
<a class="router-link" :ref="setNavRef('others')" href="/others">更多</a>
|
||||
<div class="dot">·</div>
|
||||
<a class="router-link" :ref="setNavRef('photo')" href="/photo">相册</a>
|
||||
<div class="dot">·</div>
|
||||
<a class="router-link" :ref="setNavRef('blog')" href="/blog">记事本</a>
|
||||
<div class="dot">·</div>
|
||||
<a class="router-link" :ref="setNavRef('life')" href="/life">生活</a>
|
||||
<div class="dot">·</div>
|
||||
<a class="router-link" :ref="setNavRef('about')" href="/about">关于</a>
|
||||
<div class="dot">·</div>
|
||||
<a class="router-link" :ref="setNavRef('home')" href="/">
|
||||
<svg focusable="false" data-prefix="fal" data-icon="house-night" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" class="svg-inline--fa fa-house-night fa-w-20"><path fill="currentColor" d="M112,224a111.5,111.5,0,0,0,87-41.45,20.51,20.51,0,0,0-19.75-33.08A59.2,59.2,0,0,1,138.84,39.85a20.3,20.3,0,0,0,10.07-21.27,20.26,20.26,0,0,0-16.47-16.7A136,136,0,0,0,112,0a112,112,0,0,0,0,224ZM97.78,33.27a91.21,91.21,0,0,0,54.47,147.9A80,80,0,1,1,97.78,33.27Zm97.15,35.51,39.72,16.56,16.56,39.72a5.33,5.33,0,0,0,9.55,0l16.56-39.72L317,68.78a5.33,5.33,0,0,0,0-9.54L277.32,42.68,260.76,3a5.33,5.33,0,0,0-9.55,0L234.65,42.68,194.93,59.24a5.34,5.34,0,0,0,0,9.54ZM157,379.24l-39.72-16.57L100.76,323a5.34,5.34,0,0,0-9.55,0L74.65,362.67,34.93,379.24a5.34,5.34,0,0,0,0,9.54l39.72,16.56,16.56,39.72a5.33,5.33,0,0,0,9.55,0l16.56-39.72L157,388.78a5.33,5.33,0,0,0,0-9.54Zm179-101.9v85.33A21.39,21.39,0,0,0,357.36,384h85.31A21.39,21.39,0,0,0,464,362.67V277.34A21.4,21.4,0,0,0,442.67,256H357.36A21.4,21.4,0,0,0,336,277.34ZM368,288H432v64H368Zm266.49,8L576,244.75V144a16,16,0,0,0-32,0v72.75L410.53,100a16,16,0,0,0-21.07,0l-224,196a16,16,0,0,0,21.07,24.09L224,287.28V464a48.05,48.05,0,0,0,48,48H528a48.06,48.06,0,0,0,48-48V287.28l37.46,32.78A16,16,0,0,0,634.53,296ZM544,464a16,16,0,0,1-16,16H272a16,16,0,0,1-16-16V264a15.94,15.94,0,0,0-.81-4L400,133.27l144,126Z" class=""></path></svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="nav-bookmark" ref="bookmark" :style="bookmarkCss"></div>
|
||||
<div id="nav-background"></div>
|
||||
|
||||
<img id="meru" src="/meru_256px.png" alt="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick, ComponentPublicInstance } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
currentPath: string;
|
||||
routeName: string;
|
||||
navBookmark?: string;
|
||||
}>();
|
||||
|
||||
const currentRoute = ref(props.navBookmark?.toLowerCase() || props.routeName?.toLowerCase() || 'home');
|
||||
const bookmarkCss = ref('');
|
||||
const lastTop = ref(0);
|
||||
const menuOpen = ref(false);
|
||||
const bookmarkUpdateIntervalId = ref<number | null>(null);
|
||||
|
||||
const navRefs = ref<Record<string, Element | ComponentPublicInstance | null>>({});
|
||||
|
||||
const showMenu = (): void => {
|
||||
menuOpen.value = !menuOpen.value;
|
||||
if (menuOpen.value) setTimeout(() => menuOpen.value = false, 2000);
|
||||
}
|
||||
|
||||
const setNavRef = (name: string) => (el: Element | ComponentPublicInstance | null) => {
|
||||
navRefs.value[name] = el;
|
||||
}
|
||||
|
||||
const resolveNavElement = (target: Element | ComponentPublicInstance | null | undefined): Element | null => {
|
||||
if (!target) return null;
|
||||
if (target instanceof Element) return target;
|
||||
return (target.$el as Element | undefined) ?? null;
|
||||
}
|
||||
|
||||
const calculateBookmarkCss = (): void => {
|
||||
const currentLink = resolveNavElement(navRefs.value[currentRoute.value]) as HTMLElement;
|
||||
if (!currentLink) return;
|
||||
|
||||
const top = currentLink.offsetTop;
|
||||
const h = currentLink.offsetHeight;
|
||||
if (top === lastTop.value) return;
|
||||
lastTop.value = top;
|
||||
|
||||
const width = Math.round(h / 2) + 8;
|
||||
|
||||
bookmarkCss.value = `top: ${top - 8}px;` +
|
||||
`border-width: ${width}px 20px ${width}px 50px;`;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
calculateBookmarkCss();
|
||||
window.addEventListener('resize', calculateBookmarkCss, true);
|
||||
bookmarkUpdateIntervalId.value = window.setInterval(calculateBookmarkCss, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateBookmarkCss, true);
|
||||
if (bookmarkUpdateIntervalId.value !== null) {
|
||||
window.clearInterval(bookmarkUpdateIntervalId.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
#nav
|
||||
position: fixed
|
||||
left: 0
|
||||
height: 100%
|
||||
font-size: 1.4em
|
||||
align-items: flex-start
|
||||
|
||||
z-index: 100
|
||||
pointer-events: none
|
||||
|
||||
#menu
|
||||
padding: 5px 10px
|
||||
margin: 15px 10px
|
||||
background-color: rgba(255, 255, 255, 0.49)
|
||||
border-radius: 10px
|
||||
text-shadow: -1px -1px 1px rgba(255,255,255,.1), 1px 1px 1px rgba(0,0,0,.5)
|
||||
filter: drop-shadow(0 0 30px rgb(255, 255, 255))
|
||||
|
||||
z-index: 100
|
||||
pointer-events: auto
|
||||
|
||||
opacity: 0
|
||||
|
||||
#nav-bookmark
|
||||
position: absolute
|
||||
left: 0
|
||||
width: 20px
|
||||
height: 0
|
||||
z-index: 5
|
||||
--bookmark-color: rgb(255, 225, 230)
|
||||
border-color: var(--bookmark-color) transparent var(--bookmark-color) var(--bookmark-color)
|
||||
border-style: solid
|
||||
border-width: 20px
|
||||
|
||||
#items
|
||||
justify-content: flex-end
|
||||
z-index: 10
|
||||
|
||||
.router-link
|
||||
color: rgba(128, 112, 92, 0.71)
|
||||
position: relative
|
||||
|
||||
z-index: 100
|
||||
pointer-events: auto
|
||||
|
||||
.dot
|
||||
content: '·'
|
||||
margin: 20px 0
|
||||
user-select: none
|
||||
|
||||
.router-link, .dot
|
||||
text-decoration: none
|
||||
writing-mode: vertical-rl
|
||||
text-orientation: sideways
|
||||
transform: scale(-1)
|
||||
padding-right: 20px
|
||||
|
||||
#nav-background
|
||||
position: absolute
|
||||
height: 100%
|
||||
width: 100px
|
||||
left: 0
|
||||
$nav-bg-color: #f9f2e0
|
||||
border-left: 20px solid $nav-bg-color
|
||||
background: linear-gradient(to right, $nav-bg-color, transparent)
|
||||
|
||||
z-index: 4
|
||||
pointer-events: none
|
||||
|
||||
#meru
|
||||
height: 160px
|
||||
|
||||
z-index: 100
|
||||
pointer-events: auto
|
||||
|
||||
svg
|
||||
display: inline-block
|
||||
height: 1em
|
||||
transform: rotate(180deg)
|
||||
*
|
||||
box-sizing: inherit
|
||||
|
||||
@media screen and (max-width: 800px)
|
||||
#nav #menu
|
||||
opacity: 1
|
||||
|
||||
#nav.home
|
||||
#menu, #nav-background
|
||||
opacity: 0
|
||||
|
||||
#nav:not(.home).open
|
||||
#items, #nav-bookmark, #nav-background, #meru
|
||||
opacity: 1
|
||||
animation: fade-in-left .5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both
|
||||
|
||||
#nav:not(.home)
|
||||
#items, #nav-bookmark, #nav-background, #meru
|
||||
opacity: 0
|
||||
animation: fade-out-left 1s 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both
|
||||
|
||||
@media screen and (max-width: 500px), (max-height: 660px)
|
||||
#nav
|
||||
#nav-bookmark
|
||||
width: 10px
|
||||
|
||||
#meru
|
||||
height: 120px
|
||||
|
||||
@media screen and (max-width: 370px)
|
||||
#nav #menu
|
||||
opacity: unset
|
||||
|
||||
#nav.home
|
||||
#menu, #nav-background
|
||||
opacity: unset
|
||||
|
||||
#nav.open
|
||||
#items, #nav-bookmark, #nav-background, #meru
|
||||
opacity: 1
|
||||
animation: fade-in-left .5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both
|
||||
|
||||
#nav
|
||||
#items, #nav-bookmark, #nav-background, #meru
|
||||
opacity: 0
|
||||
animation: fade-out-left 1s 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) both
|
||||
|
||||
@media screen and (max-height: 600px)
|
||||
#nav
|
||||
.dot
|
||||
margin: 10px 0
|
||||
|
||||
@media screen and (max-height: 500px)
|
||||
#nav
|
||||
.router-link
|
||||
margin-bottom: 10px
|
||||
|
||||
.dot
|
||||
display: none
|
||||
</style>
|
||||
@@ -15,11 +15,11 @@ const props = defineProps({
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import src/css/colors
|
||||
@use "../css/colors"
|
||||
|
||||
$tag-height: 20px
|
||||
$tag-color: $color-bg-6
|
||||
$text-color: $color-text-light
|
||||
$tag-color: colors.$color-bg-6
|
||||
$text-color: colors.$color-text-light
|
||||
|
||||
$padding: calc($tag-height / 2)
|
||||
$triangle-width: calc($tag-height / 2) * 0.8
|
||||
|
||||
@@ -16,51 +16,48 @@
|
||||
<div id="attachments" v-if="item.attachments.length !== 0">
|
||||
<div class="label">Attachments</div>
|
||||
<div class="content" v-for="a of item.attachments" :key="a.data.key">
|
||||
<a :href="a.links['enclosure'].href">{{a.data.title}}</a>
|
||||
<a :href="a.links['enclosure']?.href">{{a.data.title}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import {Prop} from "vue-property-decorator";
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted} from 'vue'
|
||||
import moment from "moment";
|
||||
import MetaTable from "@/components/MetaTable.vue";
|
||||
import {capitalize} from "@/scripts/utils";
|
||||
import linkifyUrls from "linkify-urls";
|
||||
import {linkifyUrlsToHtml} from "linkify-urls";
|
||||
import {$} from '@/scripts/constants';
|
||||
import {ZoteroData, ZoteroItem} from "@/scripts/zotero";
|
||||
|
||||
@Options({components: {MetaTable}})
|
||||
export default class ZoteroPublicationView extends Vue
|
||||
{
|
||||
@Prop({required: true}) item!: ZoteroItem
|
||||
const props = defineProps<{ item: ZoteroItem }>()
|
||||
|
||||
get d(): ZoteroData { return this.item.data }
|
||||
get date(): moment.Moment { return moment(this.item.meta.parsedDate) }
|
||||
get authors(): string { return this.d.creators.map(it => it.firstName + ' ' + it.lastName).join(' & ') }
|
||||
const d = computed((): ZoteroData => props.item.data)
|
||||
const date = computed((): moment.Moment => moment(props.item.meta.parsedDate))
|
||||
const authors = computed((): string => d.value.creators.map(it => it.firstName + ' ' + it.lastName).join(' & '))
|
||||
|
||||
get tableData(): {[id: string]: unknown}
|
||||
{
|
||||
const t: {[id: string]: unknown} = {...this.d}
|
||||
t.creators = this.authors
|
||||
delete t.key
|
||||
delete t.version
|
||||
delete t.title
|
||||
delete t.abstractNote
|
||||
if (t.itemType) t.itemType = capitalize(t.itemType as string)
|
||||
if (t.url) t.url = linkifyUrls(t.url as string)
|
||||
return t
|
||||
}
|
||||
const tableData = computed((): {[id: string]: unknown} => {
|
||||
const t: {[id: string]: unknown} = {...d.value}
|
||||
t.creators = authors.value
|
||||
delete t.key
|
||||
delete t.version
|
||||
delete t.title
|
||||
delete t.abstractNote
|
||||
if (t.itemType) t.itemType = capitalize(t.itemType as string)
|
||||
if (t.url) t.url = linkifyUrlsToHtml(t.url as string)
|
||||
return t
|
||||
})
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
$('.publication').accordion({collapsible: true, header: 'div.header', active: false,
|
||||
heightStyle: "content"})
|
||||
}
|
||||
}
|
||||
onMounted((): void => {
|
||||
$('.publication').accordion({
|
||||
collapsible: true,
|
||||
header: 'div.header',
|
||||
active: false,
|
||||
heightStyle: 'content'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@@ -77,6 +74,7 @@ export default class ZoteroPublicationView extends Vue
|
||||
|
||||
#details
|
||||
padding-left: calc(1.6em + 6px)
|
||||
font-size: 0.8em
|
||||
|
||||
> div
|
||||
margin-bottom: 1em
|
||||
@@ -87,8 +85,6 @@ export default class ZoteroPublicationView extends Vue
|
||||
.label
|
||||
font-weight: bold
|
||||
|
||||
font-size: 0.8em
|
||||
|
||||
.header
|
||||
align-items: center
|
||||
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
<template>
|
||||
<div id="ColorPicker" ref="el" @keydown.esc="close">
|
||||
<div id="title-colors" class="fbox-h">
|
||||
<div class="text" @mousedown="windowDrag" @click.right.alt="close">Colors</div>
|
||||
<div class="close fbox-vcenter" @click="close" v-if="showClose"><i class="fas fa-times"></i></div>
|
||||
<input v-model="colorInput" spellcheck="false" @change="colorModel = colorInput">
|
||||
</div>
|
||||
<ColorPicker id="picker" :isWidget="true" pickerType="chrome" v-model:pureColor="colorModel"
|
||||
:disableHistory="true" @pureColorChange="change" format="hex8"/>
|
||||
<div id="palette">
|
||||
<div class="row" v-for="(p, i) of palette" :key="i">
|
||||
<div class="color" v-for="(c, j) in p" :key="j" :style="{'background-color': c ? c : '#333'}"
|
||||
@click.exact="setPalette(i, j)"
|
||||
@click.right.alt="(e) => altClickPalette(e, i, j)"
|
||||
@click.right.exact="(e) => rightClickPalette(e, i, j)"
|
||||
draggable="true" @dragstart="paletteDragStart(i, j)" @drop="(e) => dropPalette(e, i, j)"
|
||||
@dragenter="(e) => paletteDragEnter(e, i, j)" @dragover="(e) => paletteDragOver(e, i, j)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import "vue3-colorpicker/style.css";
|
||||
import {ColorPicker} from "vue3-colorpicker";
|
||||
import {range} from "@/scripts/utils";
|
||||
import {Emit, Model, Prop, Ref} from "vue-property-decorator";
|
||||
import {Options, Vue} from "vue-class-component";
|
||||
|
||||
/**
|
||||
* Mouse Usage:
|
||||
* - Alt + Right-click the title to close
|
||||
* - Left-click on a color block to add color to palette
|
||||
* - Left-drag on a color block to move color
|
||||
* - Right-click on a color block to select color
|
||||
* - Alt + Right-click on a color block to remove color
|
||||
*/
|
||||
@Options({components: {ColorPicker}})
|
||||
export default class MyColorPicker extends Vue
|
||||
{
|
||||
@Model('color') color!: string // Color prop in #ffffffff format
|
||||
colorModel = '' // Color model in #ffffffff format
|
||||
colorInput = '' // Color input in ffffff format
|
||||
palette: string[][] = []
|
||||
showClose = false
|
||||
|
||||
@Ref() el!: HTMLElement
|
||||
@Prop({default: null}) initialPos?: {x: number, y: number}
|
||||
|
||||
/**
|
||||
* Init
|
||||
*/
|
||||
created(): void
|
||||
{
|
||||
this.colorModel = this.color
|
||||
this.colorInput = this.colorModel.substr(1, 6)
|
||||
|
||||
const storedPalette = localStorage.getItem('palette')
|
||||
this.palette = !storedPalette ? range(6).map(_ => range(10).map(_ => '')) : JSON.parse(storedPalette);
|
||||
this.storePalette()
|
||||
}
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
if (this.initialPos) this.setPos(this.initialPos.x, this.initialPos.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Color change
|
||||
*/
|
||||
@Emit('update:color')
|
||||
change(color: string): string
|
||||
{
|
||||
this.colorInput = color.substr(1, 6)
|
||||
return this.colorModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Set window position
|
||||
*/
|
||||
setPos(x: number, y: number): void
|
||||
{
|
||||
this.el.style.left = x + 'px'
|
||||
this.el.style.top = y + 'px'
|
||||
}
|
||||
|
||||
/**
|
||||
* Window dragging
|
||||
*/
|
||||
windowDrag(e: MouseEvent): void
|
||||
{
|
||||
e.preventDefault()
|
||||
let lastX = e.clientX, lastY = e.clientY
|
||||
|
||||
const mousemove = (e: MouseEvent) =>
|
||||
{
|
||||
const dx = lastX - e.clientX, dy = lastY - e.clientY
|
||||
lastX = e.clientX; lastY = e.clientY
|
||||
this.setPos(this.el.offsetLeft - dx, this.el.offsetTop - dy)
|
||||
}
|
||||
const mouseup = () => {document.removeEventListener('mouseup', mouseup); document.removeEventListener('mousemove', mousemove)}
|
||||
document.addEventListener('mouseup', mouseup)
|
||||
document.addEventListener('mousemove', mousemove)
|
||||
}
|
||||
|
||||
@Emit('updatePalette')
|
||||
storePalette(): string[][]
|
||||
{
|
||||
localStorage.setItem('palette', JSON.stringify(this.palette))
|
||||
return this.palette
|
||||
}
|
||||
|
||||
@Emit()
|
||||
close(e?: Event): void
|
||||
{
|
||||
if (e) e.preventDefault()
|
||||
console.log('Color picker close')
|
||||
}
|
||||
|
||||
/**
|
||||
* Left click to override
|
||||
*/
|
||||
setPalette(i: number, j: number): void
|
||||
{
|
||||
this.palette[i][j] = this.colorModel
|
||||
this.storePalette()
|
||||
}
|
||||
|
||||
/**
|
||||
* Right click to select
|
||||
*/
|
||||
rightClickPalette(e: Event, i: number, j: number): void
|
||||
{
|
||||
e.preventDefault()
|
||||
if (!this.palette[i][j]) return
|
||||
this.colorModel = this.palette[i][j]
|
||||
this.change(this.colorModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt right click to remove
|
||||
*/
|
||||
altClickPalette(e: Event, i: number, j: number): void
|
||||
{
|
||||
e.preventDefault()
|
||||
this.palette[i][j] = ''
|
||||
this.storePalette()
|
||||
}
|
||||
|
||||
dragging = {i: 0, j: 0}
|
||||
|
||||
paletteDragStart(i: number, j: number): void
|
||||
{
|
||||
this.dragging = {i, j}
|
||||
}
|
||||
|
||||
dropPalette(e: DragEvent, i: number, j: number): void
|
||||
{
|
||||
// We can assume that toI != fromI
|
||||
const fromI = this.dragging.i * 10 + this.dragging.j
|
||||
const toI = i * 10 + j
|
||||
const incr = toI > fromI ? 1 : -1
|
||||
|
||||
const currentColor = this.palette[this.dragging.i][this.dragging.j]
|
||||
for (let index of range(fromI, toI))
|
||||
{
|
||||
const col = index % 10, row = Math.floor(index / 10)
|
||||
const lastI = index + incr
|
||||
const lastC = lastI % 10, lastR = Math.floor(lastI / 10)
|
||||
console.log(lastR, lastC, 'TO', row, col)
|
||||
this.palette[row][col] = this.palette[lastR][lastC]
|
||||
}
|
||||
this.palette[i][j] = currentColor
|
||||
this.storePalette()
|
||||
}
|
||||
|
||||
paletteDragEnter(e: DragEvent, i: number, j: number): void
|
||||
{
|
||||
// TODO: Drag preview
|
||||
console.log('Drag enter')
|
||||
console.log(e)
|
||||
console.log('' + i + ' ' + j)
|
||||
}
|
||||
|
||||
paletteDragOver(e: DragEvent, i: number, j: number): void
|
||||
{
|
||||
// Only allow drag if it's not dragging onto itself
|
||||
if (!(i == this.dragging.i && j == this.dragging.j)) e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
#ColorPicker
|
||||
position: absolute
|
||||
border-radius: 20px
|
||||
overflow: hidden
|
||||
|
||||
$cp-color: #4d4d4d
|
||||
$txt-color: #cbcbcb
|
||||
background-color: $cp-color
|
||||
box-shadow: 0 0 10px #00000026
|
||||
color: $txt-color
|
||||
|
||||
#title-colors
|
||||
margin: 8px 0
|
||||
font-size: 1.2em
|
||||
padding: 0 15px
|
||||
|
||||
div.close
|
||||
font-size: 10px
|
||||
margin-right: 10px
|
||||
|
||||
div.text
|
||||
flex-grow: 1
|
||||
text-align: left
|
||||
user-select: none
|
||||
|
||||
input
|
||||
background-color: lighten($cp-color, 6)
|
||||
font-family: monospace
|
||||
color: $txt-color
|
||||
border: none
|
||||
padding: 0 10px
|
||||
width: 60px
|
||||
text-align: center
|
||||
border-radius: 8px
|
||||
transition: all 0.25s ease
|
||||
|
||||
input:focus-visible
|
||||
outline: none
|
||||
background-color: lighten($cp-color, 10)
|
||||
|
||||
.vc-colorpicker
|
||||
width: 300px
|
||||
background-color: transparent
|
||||
box-shadow: none
|
||||
padding-bottom: 0
|
||||
border-radius: 0
|
||||
|
||||
.vc-colorpicker--container
|
||||
padding: 0 3px
|
||||
|
||||
.vc-chrome-colorPicker, .vc-chrome-colorPicker-body
|
||||
background-color: transparent
|
||||
|
||||
.vc-display
|
||||
display: none
|
||||
|
||||
.vc-saturation, .vc-saturation__white, .vc-saturation__black
|
||||
border-radius: 5px
|
||||
|
||||
.vc-saturation
|
||||
height: 200px
|
||||
|
||||
.vc-chrome-colorPicker-body
|
||||
margin-left: 10px
|
||||
margin-right: 10px
|
||||
|
||||
#palette
|
||||
width: 300px
|
||||
|
||||
// Transparency texture
|
||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==)
|
||||
background-repeat: repeat
|
||||
|
||||
.row
|
||||
width: 100%
|
||||
display: flex
|
||||
|
||||
.color:first-child
|
||||
border-left: none
|
||||
|
||||
.row:first-child .color
|
||||
border-top: none
|
||||
|
||||
.color
|
||||
width: 30px
|
||||
height: 30px
|
||||
box-sizing: border-box
|
||||
border-left: 1px solid $cp-color
|
||||
border-top: 1px solid $cp-color
|
||||
flex-grow: 1
|
||||
|
||||
</style>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<div id="Projects">
|
||||
<MyColorPicker v-model:color="color"></MyColorPicker>
|
||||
<button @click="log"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
import MyColorPicker from "@/components/color/ColorPicker.vue";
|
||||
|
||||
@Options({components: {MyColorPicker}})
|
||||
export default class Projects extends Vue
|
||||
{
|
||||
color = '#ffffff'
|
||||
|
||||
log(): void
|
||||
{
|
||||
console.log(this.color)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
|
||||
</style>
|
||||
+6
-4
@@ -1,7 +1,9 @@
|
||||
@font-face
|
||||
font-family: 'WenKai'
|
||||
src: url("/fonts/LXGWWenKai-Regular.woff2") format('woff2'), url("/fonts/LXGWWenKai-Regular.woff") format('woff')
|
||||
//@font-face
|
||||
// font-family: 'WenKai'
|
||||
// src: url("/fonts/LXGWWenKai-Regular.woff2") format('woff2'), url("/fonts/LXGWWenKai-Regular.woff") format('woff')
|
||||
@import 'lxgw-wenkai-webfont/style.css'
|
||||
|
||||
.font-custom
|
||||
font-family: "WenKai", var(--font-fallback)
|
||||
//font-family: "WenKai", var(--font-fallback)
|
||||
font-family: "LXGW WenKai", var(--font-fallback), serif
|
||||
//line-height: 1.3em
|
||||
|
||||
+58
-10
@@ -1,5 +1,5 @@
|
||||
@import colors
|
||||
@import font
|
||||
@use "colors"
|
||||
@use "font"
|
||||
|
||||
// Google Fonts
|
||||
// TODO: Localize
|
||||
@@ -119,7 +119,7 @@ body
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
text-align: center
|
||||
color: $color-text-main
|
||||
color: colors.$color-text-main
|
||||
|
||||
// Max width and center
|
||||
max-width: 900px
|
||||
@@ -170,30 +170,78 @@ html
|
||||
text-justify: inter-word
|
||||
|
||||
a
|
||||
color: $color-text-special
|
||||
color: colors.$color-text-special
|
||||
text-decoration: none
|
||||
|
||||
h1, h2
|
||||
border-bottom: 1px solid $color-text-special
|
||||
font-size: 1.5em
|
||||
border-bottom: 1px solid colors.$color-text-special
|
||||
font-size: 1.6em
|
||||
margin-top: 1em
|
||||
|
||||
h1, h2
|
||||
line-height: 1.3
|
||||
margin-bottom: 0.25em
|
||||
padding: 0
|
||||
|
||||
h2
|
||||
font-size: 1.4em
|
||||
|
||||
h3
|
||||
font-size: 1.2em
|
||||
|
||||
p
|
||||
font-size: 0.9em
|
||||
margin: 0.5em 0
|
||||
font-size: 1em
|
||||
margin: 0.7em 0
|
||||
line-height: 1.6
|
||||
|
||||
p:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
li
|
||||
font-size: 0.9em
|
||||
font-size: 1em
|
||||
margin-bottom: 0.25em
|
||||
|
||||
.image-wrap, figure
|
||||
margin: 1em 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
img
|
||||
margin-left: 0
|
||||
margin-right: 0
|
||||
width: 100%
|
||||
|
||||
.caption, caption
|
||||
display: block
|
||||
width: 100%
|
||||
text-align: center
|
||||
margin-top: 0.5em
|
||||
font-size: 1.1em
|
||||
color: colors.$color-text-light
|
||||
font-family: 'Caveat', 'Shadows Into Light', cursive
|
||||
opacity: 0.8
|
||||
|
||||
img
|
||||
max-width: 100%
|
||||
border-radius: 10px
|
||||
object-fit: cover
|
||||
max-height: 500px
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
img[src*="github-readme-stats"], img[height]
|
||||
width: auto
|
||||
max-width: 100%
|
||||
height: auto
|
||||
max-height: none
|
||||
margin: 0
|
||||
object-fit: contain
|
||||
|
||||
caption
|
||||
display: flex
|
||||
width: 100%
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
font-size: 0.8em
|
||||
color: colors.$color-text-light
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "src/css/colors"
|
||||
@use "../css/colors"
|
||||
|
||||
$width: 600px
|
||||
|
||||
@@ -12,9 +12,11 @@ $width: 600px
|
||||
|
||||
h2
|
||||
margin-bottom: 0
|
||||
font-size: 1.7em
|
||||
|
||||
.subtitle
|
||||
color: $color-text-light
|
||||
color: colors.$color-text-light
|
||||
font-size: 1em
|
||||
|
||||
|
||||
// Phone layout
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
### 🐱 Hi there, I'm Azalea!
|
||||
|
||||
I'm Azalea Gui, a 1th year MEng student at the University of Waterloo. I find joy in making hardware and designing modern web frontends and doing research in machine learning and audio/speech/music signal-processing projects. I published the [FAD toolkit](https://github.com/microsoft/fadtk) during my last research project on [improving the evaluation of generative music](https://arxiv.org/abs/2311.01616). I've also been doing independent research on creating an AI transgender voice training assistant.
|
||||
|
||||
I also love rhythm games and cats =^・-・^=
|
||||
|
||||
|
||||
### 🌷 My overall statistics <!--{ collapseSection() }-->
|
||||
|
||||

|
||||
<img src="https://user-images.githubusercontent.com/22280294/179611382-5704fe4f-ef8c-40f2-b868-5921cfb56da6.png" alt="pusheen" height="160px">
|
||||
|
||||
|
||||
### 🕊️ You can reach me at <!--{ collapseSection() }-->
|
||||
|
||||
* [Blog](https://aza.moe) | [Class Notes](https://hydev.notion.site/Azalea-s-Notion-436723d16f304e0a95e4e503ae82c6f8) | [OS Monitor](https://gf.hydev.org/d/7JdIfTn9z/os-monitor)
|
||||
* Telegram [@hykilpikonna](https://t.me/hykilpikonna)
|
||||
* Discord `hykilp`
|
||||
* Email me@hydev.org
|
||||
* **NEW!** Anonymous Question Box: https://peing.net/hykilpikonna
|
||||
|
||||
|
||||
### 🌎 I can speak <!--{ collapseSection() }-->
|
||||
|
||||
* 🇺🇸 English
|
||||
* 🇨🇳 中文
|
||||
* 🇯🇵 日本語 (Learning)
|
||||
|
||||
|
||||
### ⭐ I have experience writing <!--{ collapseSection() }-->
|
||||
|
||||
* **Backend**: Kotlin / Java - (Spring, Hibernate, Kotlin coroutines, ...)
|
||||
* **Frontend**: TypeScript + HTML + Sass - (Svelte, SolidJS, Vue3, ...)
|
||||
* **Research**: Python 3.12 - (pytorch, numpy, tensorflow, sklearn, pandas, xgboost, ...)
|
||||
* **CLI Tools**: Rust 🦀
|
||||
* **Embedded**: C++ (PlatformIO Arduino toolchain), EasyEDA (JLCEDA)
|
||||
* **Mobile**: Kotlin for Android, Swift for iOS
|
||||
|
||||
### 🔮 Open-source Everything! <!--{ collapseSection() }-->
|
||||
|
||||
I aim to open-source not only code, but everything I make! This includes hardware projects and other multimedia projects. Check them out!
|
||||
|
||||
* 🎼 [Music](https://github.com/hykilpikonna/Music)
|
||||
* 🪛 [Hardware](https://github.com/hykilpikonna/OpenHardware)
|
||||
|
||||
### 🛠️ Projects I'm actively maintaining <!--{ collapseSection() }-->
|
||||
|
||||
* [Amaoke](https://github.com/MaigoLabs/amaoke.app): Japanese karaoke lyrics-reading/typing practice app
|
||||
* [AquaDX](https://github.com/hykilpikonna/AquaDX): An arcade server for the modern age
|
||||
* [HyFetch](https://github.com/hykilpikonna/hyfetch): Neofetch with LGBTQ+ pride flags
|
||||
* [Corner](https://github.com/hykilpikonna/corner): My corner of the internet
|
||||
|
||||
### 🌱 Projects I'm currently working on <!--{ collapseSection() }-->
|
||||
|
||||
* `40%` A transgender voice training app. [voice.hydev.org](https://voice.hydev.org/)
|
||||
* `20%` A distributed, federated anonymous question platform.
|
||||
|
||||
### 🌲 Completed projects <!--{ collapseSection() }-->
|
||||
|
||||
~~I published many projects to [HyDEV (HyDevelop)](https://github.com/hydevelop), previously a two-person organization of me and my best friend [Vanilla](https://github.com/vergedx) before we broke up three year ago. Currently, I publish my projects to this personal account.
|
||||
There is a list of my significant projects on my [projects website](https://me.hydev.org).~~ (TODO: Ok this project page is 4 years out-of-date I seriously need to update this ;-;)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: 《尚气》感想
|
||||
subtitle: 桂桂评分:★★★★★
|
||||
tags:
|
||||
- 电影
|
||||
category: 电影
|
||||
---
|
||||
|
||||
今天去看了《尚气》,是我最喜欢的漫威电影了!虽然很想写影评,但是我其实很少看电影,也没有精力做严肃的分析和比较,所以就很随意地写写喜欢的地方和我得到的东西好啦w
|
||||
|
||||
首先,这部电影对团体功劳的重视改变了我对美国剧本个人英雄主义的印象——这部电影中几乎每件成就都是缺了任何人都无法完成的。Katy 是没有经过特殊训练的普通人,打戏似乎没有她能插手的,但是她的长处和贡献也至关重要,比如在巴士上的那段打戏和去大罗的惊险路程中开好巴士,又比如在最终的战斗中射出解开困境的一箭。很喜欢 Katy 的自知之明——现实中没有什么是个人英雄能解决的,知道自己擅长什么并把擅长的事情做好就是对解决这些问题的最大的帮助吧。
|
||||
|
||||
说到打戏,传统道家武术的温柔感和漫威 CG 的现代感结合起来真的很美!即使现实中母亲温柔善良的道家武术和父亲强硬果断的搏击武术相比很不实用,道家无疑更美。那两段感化了父亲的打戏也充分强调了一个温柔的心灵拥有的力量——与让仇人灰飞烟灭的强硬力量不同,这是让一位征服者放下拳头重审自我、互相理解的力量。强硬的做法往往是固执于自己的成见,就像父亲固执于相信征服是人生的乐趣、相信重新戴上十环就能解决一切、相信诱导着他的声音,击垮挡在面前的人,直到这两场温柔美丽的打戏。
|
||||
|
||||
从父亲的固执中,传统权威家庭的压迫感和与进步思维的纠纷也传达到了。在穿插的回忆的片段里,尚气从出生就被赋予的继承期望、完成强加给他的残酷训练时迷茫的表情、看到父亲残忍的杀戮后咽下口水轻轻点头... 这些细节很深刻地传达了尚气对父亲的恐惧。在这样的传统家庭里我们永远是需要长大的小孩子,家长直到亲眼看到自己的错误前我们的意见永远是错的。逃出家长为我们规划的人生路线,走上独立思考后为自己决定的轨迹真的是很需要勇气和能力的事——不是证明自己让家人理解的能力,就是离开对家人的依靠独立生活的能力。尚气独立成长的时光中获得了拦在父亲面前的勇气和力量,可惜父亲还是选择了亲眼见证自己的错误。现实中,这一代的父母思维进步很多了,但是还有很多需要时间改变的事情,希望每个人都能拥有在权威面前坚持自我的勇气。
|
||||
|
||||
这里还回应了传统观念的性别偏见。父母生下一男一女,男孩被寄予了继承的期望,女孩却被禁止参加训练,但是夏灵自学练出的结果说不定比尚气更好(这里并没有给明确的比较,应该是因为结果怎样都不太好,但是我更愿意相信这是想表达我们不需要和别人比较来评估自己的能力。)让夏灵有决心自己练好武术、成立地下搏击团的,是她反抗训练禁令、反抗刻板印象的心理状态。我相信就像夏灵那样,只要不让刻板印象成为自证预言,任何人感兴趣并且投入时间和资源去学某件事情都能做好。
|
||||
|
||||
「这里不是你父亲的家,在大罗每个人都可以练武。是时候从影子里走出来了」
|
||||
|
||||
最后,也许对我影响最深的,是尚气在成长中对困扰着很多留学生的无归属感的和解。尚气很小就离开了母亲家乡大罗道家教育的保护,进入了父亲的十环帮;十四岁就离开了中国,来到了洛杉矶。他既不属于大罗,也不属于十环帮;既不属于中国,也不属于美国——这样一种充满自我怀疑的无归属感一直伴随着他。但是在他和姨母对话之后,他的那场结合了太极和搏击的打戏中也能看出他的和解:他理解了他不需要归属于任何一边,这一段段不同的背景就是他的身份——与其纠结属于大罗还是属于十环帮,不如接受自己是这两边的中间者,把属于你的两边优点结合起来。同样的,与其纠结自己是中国人还是美国人,不如接受自己是两边之间的留学生,所以这就是我作为留学生看完《尚气》的感想,并没有觉得它辱华,反而成为了我最喜欢的漫威电影w
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Clive Wearing 的日记
|
||||
subtitle: 十五秒的记忆、开心又无意义的人生、人的自私愿望
|
||||
title_image: "Clive Wearing journal.png"
|
||||
tags: [心理]
|
||||
category: 想想想
|
||||
---
|
||||
|
||||
这是一个海马体因为脑炎失效的人写的一段日记——他没办法形成新的长期记忆,只拥有十五秒的短期记忆,因此他的时间是静止的,一遍一遍重复地体验着康复后第一次苏醒的喜悦。
|
||||
|
||||
主观上,他也许是世界上最快乐的人,因为没有别人能像他那样重复体验注定短暂的幸福。回想一下这一年我最幸福、让我抑制不住微笑的那些瞬间——被这所学校录取、在三院拿到 HRT 处方、第一次穿短裙出门、在恋人身边入睡——这些最幸福时刻和人生的总长相比仿佛转瞬即逝。但是客观上,他的人生也最无意义的,每天的生活都一模一样,没有思想能被保留下来。如果你可以选择的话,你会想要这样无比开心却无意义的人生嘛?
|
||||
|
||||
这里还有一件奇迹:他的恋人黛博拉到现在一直都在照顾他,到底要多么坚强才能照顾这样时间静止的人... 每次见面都像长眠醒来第一次见面一样充满一成不变的热情。如果是我,我肯定会被这样的热情压垮吧,又或许会觉得每天重复的对话无聊。而且更重要的是,虽然陪伴他会让他更开心,但是他并不会记得黛博拉为他做的事,可是黛博拉即使知道自己的付出不会被记住却仍然选择了付出... 如果我的恋人没法记得我做的事情,我还会为ta付出嘛?
|
||||
|
||||
想起来小圆里的这一段对话:
|
||||
「你希望他能实现愿望吗?
|
||||
还是你想成为为他实现愿望的恩人呢?」
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: 651层
|
||||
title_image: "earth.jpg"
|
||||
tags: [梦]
|
||||
category: 梦
|
||||
---
|
||||
|
||||
今天又做了一个特别特别奇怪的梦,试试把它写成一段故事w
|
||||
|
||||
梦见坐电梯,狭小的电梯里挤了很多人。我要去 28 层,可是电梯快要停下的时候楼层突然从 28 变到了 651 层... 看电梯里的其他人并没有很惊讶的反应,应该是正常情况,显示错了吧。我下去了,走廊里特别华丽,但是找房间的时候发现房间号也全都是 651 开头,从边上的无框窗向下看能直接看到半个地球...
|
||||
|
||||
我赶快回到电梯口按了下,刚碰到按钮门就打开了。这次是很华丽的电梯,四边都是无框窗,中间看起来像旧时代的写字桌上有无数个楼层按钮。我按下 28 层,电梯关上门,说了一句 "calculating action",两声警报后突然开始自由落体... 我的脚稍微离开地面,浮在电梯中心。几秒后缓慢减速,写字桌收进地面,四面的墙也向上升起,变回了那个狭小的电梯。停下来,我的脚回到地面,门开了,是平常的 28 层。
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: 日记 - 和聊聊相遇
|
||||
tags: [日记]
|
||||
category: 日记
|
||||
---
|
||||
|
||||
突然有一种认真把经历的事情记录下来的冲动,那就从今天开始吧w
|
||||
|
||||
今天去和聊聊看了西区故事,是一个罗密欧与朱丽叶为原型的歌舞剧。故事的男女主在敌对的帮派,模糊的归属感让他们可以独立思考、走出对帮派的无脑跟随,也是这个共同点使它们一次相遇便相爱。当然和罗密欧与朱丽叶一样,结局无比沉痛。好久没有看过情绪这么沉重又细腻的故事了,上一个这样的故事或许是利兹与青鸟,或许是朝花夕誓,又或许是凝冰剑斩的某部视觉小说。这些故事真的好美——是期望和绝望的华尔兹,甜蜜中穿插着刺痛…
|
||||
|
||||
昨天 K(前任)和我说了好多话,ta在上一段感情里和我一样是依恋的那一方,同样喜欢着一个也许不是那么喜欢自己的人。「你不要我我就没有别的东西了」——原来ta也有过把一个人看作自己的一切的经历。只是那个人更狠,直接把ta拉黑删除…所以ta讨厌浪漫、讨厌甜腻的话语、以及像ta前任对待ta一样地对待我,也许都是出于自我保护吧——不想再因为浪漫受伤,也不想让我走过同样的经历。
|
||||
|
||||
这样看来,也许我永远也不会长大呢。和前前任子兰经历过一次这样痛苦的分别之后,我依然期待着浪漫,遇到 K 依然为ta付出了我的全部,对下一个喜欢的人也一定不会变。
|
||||
|
||||
K 的聊天记录里ta和前任分开的时候朋友和ta说:“我太现实了,你看到的是之后的美好结局,我看到的是当你真正知道那个结局没法实现的时候你有多伤心。我想很可能是错的,但是我没法否定这个结果。”也许是这件事让 K 在亲密关系里变得现实,我知道ta父母有意见、我们能力差很远、目标也很不同,现在意识到我们只能分开也真的很伤心 。但这难道不是个悖论嘛?因为有可能失败就不去尝试的话,就把失败从可能变成了必然。我更喜欢知道一个愿望很难实现仍然付出全部去追求的理想主义。
|
||||
|
||||
又或者,说不定我在享受浪漫的痛苦呢?
|
||||
|
||||
(留坑)
|
||||
|
||||
看完电影和聊聊去逛优衣库,和她一起挑了点春天的女装。她给我挑了一顶很合适的帽子!还好大方地帮我结帐了…聊聊真的对我太好了,以后一定要回报给她ww
|
||||
|
||||
在优衣库那里还遇到了一位好像是跨男的亚裔路人,好心地走上来问咱是不是跨。虽然在这边第一次偶遇跨性别亚裔还是好新奇,但是即使是被跨鉴跨也真的好受打击... 不过之后ta问我们是不是一对,看我们像恋人又不是的样子就疯狂 ship 我们,还给了好多祝福什么的。然后!挥手道别之后聊聊顺势用西区故事里面超可爱的台词和我告白了!!以下是我今天疯狂想记下来的事情展开ww
|
||||
|
||||
(和路人挥手道别之后)
|
||||
:所以我们是一对嘛?
|
||||
:是吧(捂脸)
|
||||
:「quiero estar contigo para siempre」
|
||||
(然后牵住我的手)
|
||||
|
||||
(哇啊啊啊啊啊真是太棒了!!!谢谢你,聊聊!还有不知名的英雄路人!我的心要跳出去了ww)
|
||||
|
||||
这边优衣库店员也超级好心,聊聊虽然那天的打扮不是很过,但是带着裙子去试衣间店员也什么都没问,加拿大真好。聊聊买了两件很合适的毛衣、好厚的黑色半身裙、打底裤、还有给我买的帽子w
|
||||
|
||||
接下来去吃了饭,因为到处都排很长队或者没开,所以就去了一家没吃过的越南河粉店,边聊边吃了好久。可惜聊聊似乎不是很喜欢吃碳水,更喜欢吃肉肉,所以把肉肉夹给她了!决定了下次吃烤肉w
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: 日记 - Royal Ontario Museum
|
||||
tags: [日记]
|
||||
category: 日记
|
||||
---
|
||||
|
||||
今天和聊聊去逛了 Royal Ontario Museum,是我这两年第一次逛博物馆。这个博物馆真的好大,从十点开馆逛到五点半闭馆只看完了不到三层,又或许是因为我们看得比较认真,每件感兴趣文物、标本旁边的介绍都略读了下。晚上腿好酸,从来没有站过这么久。
|
||||
|
||||
在这里我也体会到了和聊聊知识量上的差距有多么恐怖,简直是肉身维基了…看到中国韩国的文物,聊聊看到材料和风格就猜到了文物的制作朝代、年份、位置,留下我在旁边偷偷查各个朝代都在哪些年。给我讲了好多,比如释迦牟尼虽然有十大弟子,摆在旁边的通常是阿难和大迦叶;比如在化学合成颜料之前,制作颜料通常需要找到特定颜色的自然材料,甚至有用木乃伊尸体磨成的棕色颜料绘出的名画;又比如华昇最早在中国发明的活字印刷并没有欧洲古腾堡的印刷术便利实用。生物知识也是,好多次被聊聊吐槽我到底翘了多少节生物课…多亏聊聊我今天刚知道海豚也是鲸目、鲸要升到水面呼吸、翼龙并不是鸟…更丢人的就省略了(x
|
||||
|
||||
我们之间五年的年龄差真的好可怕,也许这是我永远没办法赶上的。这几天还发现学计算机的聊聊懂的心理知识可能比心理专业的我还多,汉语英语日语法语都比我厉害,玩游戏也好厉害…聊聊这么优秀的真的让我好自卑。不过也没关系,我也有绝对自信的领域,我也有聊聊没有的知识…吧。
|
||||
|
||||
不管怎样,今天学到了好多!希望我能记住。不过以前或许学过这些,但是我长短期记忆都真的好差…短期比如我经常会走进一个房间就忘了进来做什么;长期比如之前母上给我看小学去一个沙漠旅游的照片,但是我完全不记得去过那里,看着照片也只想起了好像有这回事,没办法唤起情节记忆。所以其实写日记还有另一个目的——希望写下这些文字的过程能让这段记忆深深刻在记忆中。
|
||||
|
||||
晚上我们去吃了烤肉,在餐厅拿号之后要等一个小时才有位置。等消息的时候又去逛了一次 Game Stop,我终于下定决心入手了一直想要买的 NS!是 Wii 之后我的第二个任天堂主机!(虽然说快 2022 了还原价买 2017 的主机听起来很蠢,但是能借聊聊的卡带一起联机就值了ww)聊聊还给我买了马里奥奥德赛的卡带!本来还以为自己不是很爱玩游戏的,结果凌晨醒来开箱之后就一下子沉迷玩了四个小时。
|
||||
|
||||
烤肉很好吃!今生第一次吃烤肉,上生肉自己动手烤也是好神奇的体验。然后发现,比起照顾自己我们都更喜欢照顾对方的样子,会看到聊聊烤完把肉肉夹给我,我烤完再把肉肉夹给她ww
|
||||
|
||||
记得今天睡觉之前和书聊天,书说像我之前那样太在意别人不在意自己也不太好,对此我回复说聊聊会在意我的。这样互相照顾好开心!
|
||||
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: 用校园网搭建服务器!
|
||||
subtitle: 在宿舍折腾网络的一篇记录w
|
||||
tags: [技术]
|
||||
category: 技术
|
||||
---
|
||||
|
||||
欢迎欢迎!我是 UofT 的大一新生,这是我在宿舍折腾网络的一篇记录w
|
||||
|
||||
## 1. 搭建 FRP 实现内网穿透
|
||||
|
||||
|
||||
|
||||
## 2. OpenWRT 中继学校 WPA-EAP 网络实现远程重启
|
||||
|
||||
搭建好内网穿透之后又遇到了新的问题——因为台式机服务器装的黑苹果配置不是很稳定,它有时候会整个屏幕绿掉,必须强制重启才行,应该是 kernel panic 吧。我不在宿舍的时候回宿舍重启又太麻烦了,如果可以远程重启...
|
||||
|
||||
想起来之前摸鱼的时候看到向日葵有做过远程重启的设备,所以就去查了下,结果发现就只是一个智能插座,在主板上配置好插电自动开机就好啦。但是这又有一个问题——宿舍里所有的网络,无论是 UofT Wifi 还是 eduroam 还是有线以太网,都需要 WPA/WPA2/WPA3-Enterprise EAP PEAP MSCHAPv2 用户名+密码验证... 可是智能插座只支持简单的 WPA-PSK 密码验证。所以就需要一个可以连上外网、又可以开 WPA-PSK WiFi 的无线路由器了。
|
||||
|
||||
刚开始,我想过几个不同的方案:
|
||||
|
||||
### a. 用手机连 Wifi 开热点(失败)
|
||||
|
||||
之前无意间发现了我的 Mi Mix2S (Android 11 EvolutionX) 可以边连 Wifi 边开热点,不知道是怎样实现的。所以我想,如果用旧的华为 Mate8 这样一边连着学校的 PEAP 一边开热点就可以了。但是开机之后,我发现 Mate8 的原生系统 Android 8 EMUI 8.0 并不支持同时连 Wifi 开热点。我听说用 VPN Hotspot 可以实现无线中继,下载试了下却发现没有 Root 就不能改 SSID 和密码,每次开的 SSID 和密码都是随机的。所以花了超级多精力解锁 BootLoader、刷 Magisk、然后发现 Root 之后也不能改无线中继的 SSID 和密码... 改完之后重开中继的 SSID 还是 DIRECT-{随机}-HUAWEI, 密码还是八位随机字符。不过还是试了下用智能插座连这个无限中继,只能赌无线中继不会重启、不会关机、不会被电源管理杀掉...
|
||||
|
||||
结果是,在台式死机之前智能插座就先下线了,回宿舍看了一眼发现手机不知道为什么重启进 Recovery 了。失败失败(
|
||||
|
||||
### b. 用手机连数据卡开热点(太贵了)
|
||||
|
||||
然后想到如果可以再买一张手机卡只用来开热点就解决了嘛,但是发现这边不像国内那样 ¥10/mo 就能买一张卡,这边最便宜的流量卡要 CA$15/mo,换算出来要 ¥75/mo 了,够买一个路由器了(
|
||||
|
||||
### c. 用路由器中继
|
||||
|
||||
然后就是现在的解决方案啦,用路由器中继学校的 PEAP。首先要做的是买一个路由器,可是这边新的路由器也真的很贵,亚马逊上最便宜的 TP-Link AC750 要 $35 = ¥175... 所以去二手市场逛了逛,很幸运的看到了附近有人 $10 出路由器,一个 Netis WF2780 和一个小米路由 R4。
|
||||
|
||||
首先试了 WF2780,但是它好像没接好 WAN 就完全不能用的样子,SYS 灯一直在闪,网线连上电脑也没办法获取到 IP,手动分配 IP 到 192.168.1.2 也进不去管理页面。可能是坏了吧,也可能这个路由器本来就是这样?不懂,下一个(
|
||||
|
||||
然后试了 R4,可是用小米官方的固件,无限桥接模式并不能连上 PEAP 验证的 UofT Wifi 或者 eduroam,有线桥接模式把 WAN 口接上也提示识别错误。所以只能试试 OpenWRT 啦。
|
||||
|
||||
### 1. 给小米路由 R4 刷 OpenWRT
|
||||
|
||||
这里我跟了 https://www.wyr.me/post/619 这个教程,很轻松地就刷好了!
|
||||
|
||||
在电脑上要做的事情:
|
||||
|
||||
1. 网线连上路由器,登录管理后台(我是 http://192.168.31.1)
|
||||
2. 复制管理后台登录之后 URL 里面的 stok 参数
|
||||
3. 执行以下指令,输入路由器的 IP 和刚才复制的 stok
|
||||
|
||||
```sh
|
||||
git clone https://github.com/acecilia/OpenWRTInvasion
|
||||
cd OpenWRTInvasion
|
||||
pip3 install -r requirements.txt
|
||||
python3 remote_command_execution_vulnerability.py
|
||||
```
|
||||
|
||||
然后就有 ssh 啦!接下来用了这个固件:https://github.com/ioiotor/mir4-ss,下载 [openwrt-ramips-mt7621-xiaomi_mir4-squashfs-kernel1.bin](https://github.com/ioiotor/mir4-ss/releases/download/V19.07.4/openwrt-ramips-mt7621-xiaomi_mir4-squashfs-kernel1.bin) 和 [openwrt-ramips-mt7621-xiaomi_mir4-squashfs-rootfs0.bin](https://github.com/ioiotor/mir4-ss/releases/download/V19.07.4/openwrt-ramips-mt7621-xiaomi_mir4-squashfs-rootfs0.bin)
|
||||
|
||||
4. 在电脑上执行以下指令:
|
||||
|
||||
```sh
|
||||
mkdir temp
|
||||
cd temp
|
||||
wget https://github.com/ioiotor/mir4-ss/releases/download/V19.07.4/openwrt-ramips-mt7621-xiaomi_mir4-squashfs-kernel1.bin
|
||||
wget https://github.com/ioiotor/mir4-ss/releases/download/V19.07.4/openwrt-ramips-mt7621-xiaomi_mir4-squashfs-rootfs0.bin
|
||||
python3 -m http.server
|
||||
```
|
||||
|
||||
5. 登录进 SSH Shell 之后在路由器上执行指令:
|
||||
|
||||
```sh
|
||||
cd /tmp
|
||||
wget http://{电脑 IP 地址}/openwrt-ramips-mt7621-xiaomi_mir4-squashfs-kernel1.bin
|
||||
wget http://{电脑 IP 地址}/openwrt-ramips-mt7621-xiaomi_mir4-squashfs-rootfs0.bin
|
||||
mtd write openwrt-ramips-mt7621-xiaomi_mir4-squashfs-kernel1.bin kernel1
|
||||
mtd write openwrt-ramips-mt7621-xiaomi_mir4-squashfs-rootfs0.bin rootfs0
|
||||
nvram set flag_try_sys1_failed=1
|
||||
nvram commit
|
||||
reboot
|
||||
```
|
||||
|
||||
然后就好啦,重启之后就是 OpenWRT 了(虽然默认没有开 WLAN,所以只能网线连到路由器)
|
||||
|
||||
### 2. OpenWRT 连接到 PEAP 网络
|
||||
|
||||
登录上管理页面,改完管理密码之后,我试了直接把 wlan0 改成 client mode 去连学校的网,但是发现这里也只支持密码验证的 WPA-PSK 而不支持用户名+密码的 WPA-EAP。然后查了一下发现只要把预装的 wpad-basic 换成完整的 wpa-cli + wpa-supplicant + hostapd 就可以了。但是还有一个问题就是我的路由器现在没有网,所以不能在线安装软件包。我的解决方案是用电脑先连上学校的 WIFI,再把电脑的网线连到路由器的 LAN 口上,然后在电脑上开桥接模式路由器就有网了!
|
||||
|
||||
之后,ssh 进路由器,执行以下指令:
|
||||
|
||||
```sh
|
||||
opkg update
|
||||
opkg remove wpad-basic
|
||||
opkg install wpa-cli wpa-supplicant hostapd nano
|
||||
reboot
|
||||
```
|
||||
|
||||
等重启好之后,管理页面里面 Wireless Security 那一栏的验证方式里面就有 WPA2-EAP 了!接下来把学校的验证信息填进去,然后保存就连上啦!UofT 的配置如下:
|
||||
|
||||
```txt
|
||||
Encryption : WPA2-EAP (strong security)
|
||||
Cipher : auto
|
||||
EAP-Method : PEAP
|
||||
...
|
||||
Certificate Constraaint (Domain) : radius.wireless.utoronto.ca
|
||||
...
|
||||
Authentication : EAP-MSCHAPv2
|
||||
Identity : 你的 UTORid
|
||||
Password : 你的 UTORid 密码
|
||||
```
|
||||
|
||||
接下来把 WIFI 配置一下,让智能插座连上这个 WIFI 就好了!
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Snowy World
|
||||
subtitle: 一个 telnet 协议的 ASCII 动画
|
||||
---
|
||||
|
||||
![[Pasted image 20230309032722.png]]
|
||||
|
||||
写了一个 telnet 协议的 ascii 动画!(花了一整天又没什么用,但是好好玩ww
|
||||
|
||||
用指令 `telnet hydev.org` 连接哦。记得用一个好用一点的终端,比如 kitty / iTerm2
|
||||
|
||||
![[Pasted image 20230309040403.png|用 cool-retro-term 登录 ptt.cc 论坛的 telnet]]
|
||||
|
||||
昨天和鱼塔闲聊,从某紫底绿字网页聊到复古终端又聊到 telnet 论坛。这是我第一次见到 telnet 协议的论坛哇,连进 ptt 和水木论坛转了转,完全被五颜六色的字符画和各种小彩蛋吸引住了 qwq 决定我也写一个简单 ascii 动画放进我的小小角落...
|
||||
|
||||
...然后就这样花掉了一整天 🌚
|
||||
|
||||
刚开始用 python 两个小时写好了 telnet 监听、雪花、字符画、移动,简简单单地一层一层字符打上去,但是这样会让图层闪来闪去。然后改成了存 framebuffer 2d 矩阵,先把不同图层叠到缓存里再统一渲成一层命令行能懂的控制符,但是 py 矩阵遍历真的好慢好慢,用上 numpy 也依然非常慢,渲染一帧要 42ms。好像这个问题并没有解... 用 numba JIT 把遍历函数编译成二进制也只优化到了 16ms。无奈啊,可能方便的语言就是跑不快,借这个契机开始学 rust 了!
|
||||
|
||||
![[Screenshot from 2023-03-08 03-06-28 1.png|Python 渲染日志,一圈需要 45ms]]
|
||||
|
||||
rust 确实好不方便,因为不能有全局变量,用 struct + impl 强行模拟了 OOP 的对象,把全局全都写在 struct Main 里面 🌚。但是有时候又不能当作对象,遇到了比如可变指针借用的问题,把常量和变量分开存解决了,还有生命周期什么的,总之是好多写完了也没太学懂的概念。昨天折腾到凌晨五点 rua 完了,没想到同一个 2d 数组遍历只是用 rust 重写就只占用 0.12ms 了哇,350 倍啊 350 倍!
|
||||
(╯’ – ‘)╯︵ ┻━┻
|
||||
|
||||
~~就此立 flag,以后新坑绝对不会再碰 python 了~~。明明同样是 O(n * m) 的代码居然会差这么多... 现实中优化算法常量也很重要呢
|
||||
|
||||
![[Screenshot from 2023-03-08 10-26-16.png|Rust 可变 &self 指针借用的报错]]
|
||||
|
||||
接下来想要把写完的命令行程序接到 telnet 协议上,可是 rust 没有做好的 telnet 服务器库,怎么办呢?先是试着用 tokio 手搓一个协议库,发现没办法读未缓存的原始键盘事件,去 SO 问了。之后尝试调用 c 的 libtelnet 库,跑起来到处报 SIGSEGV 呜呜呜。最后还是直接用 py 的代码开子进程做了一个中继,收到客户端的输入转发给 rust,收到 rust 的输出再转发给客户端... 意外地效果还不错。
|
||||
|
||||
写完装进 docker 里面部署上去啦。花了一整天,但是学到了 rust,动画的效果也不错,总之很开心。写着写着有点想用这个框架写一个小游戏... 但是不太会游戏设计呢,再想想。
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: 开源打击琴!第二天
|
||||
subtitle: 绝赞设计中 ⭐️
|
||||
tags: [open-keyprec]
|
||||
---
|
||||
|
||||
#open-keyprec 开源打击琴的第二天
|
||||
|
||||
今天在设计琴键布局呢,决定了做五个八度的马林巴琴布局,从 C2 到 C7 一共 61 个琴键。为了不用手动复制 60 遍写了脚本自动生成琴键!FreeCAD 可以用 py 写脚本真是省了不知道多少时间哇(虽然学 API 可能用了更久hhh)。原本是按照 78-194mm 等比例分布琴键长度,但是觉得看起来太规整了,改成用三点的方式找到了一个抛物曲线计算长度,效果好棒!
|
||||
|
||||
![[Pasted image 20230315205011.png|生成的 61 个琴键模型,高度是条抛物线]]
|
||||
|
||||
然后算下来打印时间大概要 91.5h,如果 24h 无间断打印的话要 3.8 天... 今天发现 Cura 的一个实验性功能可以让多个模型在同一个平台上顺序打印,很灵车但是好想试试,如果可以的话睡觉的时候也能让打印机工作了(打印机加油!
|
||||
|
||||
真正打印的时候才发现这个打印机有好多问题,果然不应该相信半价全新... 首先是自动调平不工作,而且因为这款 "Smart" 打印机的受众是富家小孩,设计师去掉了需要耐心的手动调平旋钮。研究了一下发现是固件逻辑每次回零都会把调平 Mesh 关掉,也就是说自动调平完全没有用到。解决方案就是在 GCode 开始段回零之后加一句 `M420 S1` 重新启用调平... 创想啊你完全不测试你的代码么 (╯’ – ‘)╯︵ ┻━┻
|
||||
|
||||
![[Pasted image 20230316081122.png|自动调平 Mesh 可视化(好歪!)]]
|
||||
|
||||
然后看到印床底下有一颗孤零零的螺丝 ;-;;; 估计要拆下整个 y 轴才能取出来,好麻烦啊,不过有一点点小风险也没关系?
|
||||
|
||||
![[IMG_20230316_080312.jpg|卡在床下面的螺丝]]
|
||||
|
||||
今天下午和聊聊去拼装模型爱好者的店买了喷漆罐,打算喷成 V7530BCF 那样的金属黑色。给 PLA 喷漆也很讲究呢,需要先喷 Primer,喷漆,再喷保护层,还要一个通风柜... 原本想在厕所开上换气喷,但是看到成分里含有机溶剂,只能等天暖在外面喷了呜呜。
|
||||
|
||||
晚上复用器芯片到了,因为 D1 ESP32 控制板只有六个模拟输入,用来接 61 个琴键肯定不够,所以就改成了用四个 16 输入的复用器去读四个输入啦。去焊了四片芯片上的 96 个引脚。开始焊之前觉得 96 个引脚好多好可怕,但是熟练之后发现其实可以很快的,焊最后一个芯片的 24 根引脚只花了两分钟,离成为一个合格的工程师更近了!零件还缺 60 个 1MΩ 电阻和 60 个二极管和一些转接头就收集齐了!
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: CyberSci 2023 CTF 网安比赛 Writeup
|
||||
subtitle: 你是来打网安还是修 Bug 的?🌚
|
||||
tags: [ctf]
|
||||
---
|
||||
|
||||
我解的五道题都是 Defense 分类的,Defense 规则是修好一个服务器端让攻击方的 PoC 脚本失效就能拿分(这不是我维护服务器日常做的事情吗 ;-;
|
||||
|
||||
前三道题「CFP」是一套 PhP + Nginx 写的用来上传作业文档的服务器,后两道题「Swag Shop」是一套 Go GIN 写的购物服务器,全都跑在 docker compose 容器里(然而这两个语言我都没用过呜呜呜)
|
||||
|
||||
知道问题的时候修一个 bug 很容易,但是 PoC 机并不会告诉你 bug 在哪里,所以解题的第一步是去查 HTTP log 找到被攻击的方法。
|
||||
|
||||
## 前三道题 - Call for Presentations
|
||||
|
||||
### Attack 1
|
||||
|
||||
前三道题的部署结构我真的太熟悉了,因为我自己的所有服务器都是 Nginx + Docker,直接 `tail -f /var/log/nginx/access.log` 读出所有的请求,整理出一个攻击从开始到结束的所有请求,再 `cat /tmp/nginx-req-body/{n}/` 来读出每个请求内容。看一下请求过程如下:
|
||||
|
||||
1. 注册一个帐号
|
||||
2. 登录这个帐号
|
||||
3. 换一个不同的密码登录这个帐号还登进去了??
|
||||
|
||||
啊所以难道给我的这套代码登录甚至没有验证密码的吗 (╯’ – ‘)╯︵ ┻━┻
|
||||
|
||||
![[2023-11-18 22-02 2.png|欣赏一下屎山代码]]
|
||||
|
||||
看看源代码 lib.php 确实数据库密码验证逻辑写得乱七八糟,数据库直接 string concat,密码甚至没 hash... 好直接让 GPT4 重写了一遍登录和注册这两个函数,然后 PoC 就失效了,拿分拿分 🌚
|
||||
|
||||
![[2023-11-18 22-03.png|GPT4 帮我修好了!]]
|
||||
|
||||
(不过我至今没弄懂 PhP 要怎样把 log 打到能看到的地方... 打比赛的时候试了几个不同的方法都看不到 log 就算了算了直接把 curl hydev.org/say 写进去了,发到 /say 的东西会转发到我的 Telegram 上hhhh
|
||||
|
||||
### Attack 2
|
||||
|
||||
第一个好简单。第二个攻击的请求乍一看好像没什么问题,但是仔细看看就会发现 PoC 机注册的帐号的名字和请求提交的 JWT Token 名字不一样然后还莫名过签证验证了... 为什么呢?
|
||||
|
||||
![[image_2023-11-18_21-58-35 1.png|第二个攻击的 Log]]
|
||||
|
||||
看一眼代码确实,签名居然不是密码学签名,而是直接把请求 JWT 里面的东西 hash 了一遍,验证签名和 hash 相等???🫠 这种客户端能生成的东西还算签名吗?算了交给 GPT4 去改,果然改完就过了。
|
||||
|
||||
### Attack 3
|
||||
|
||||
第三个攻击感觉是最简单的,看日志内容发现用户上传了五个不同的文件,但是看请求内容那一步就马上发现不对劲了——五个文件中有一个是 PhP 文件,里面是攻击代码,居然直接以 .php 结尾被传进代码旁边的子目录了。泰经典辣,甚至没有用 GPT4,直接给 portal.php 上传文件的代码加一个文件结尾检查就过了(虽然如果是现实中真的遇到这种情况的话肯定要检测文件内容的)
|
||||
|
||||
## 后二道题 - Swag Shop
|
||||
|
||||
上一道题因为是 PhP,改完代码直接 `compose up --build -d` 重新跑容器也只要几秒。但是这两道题是用 Go 写的,而 Go 是需要编译的语言,所以发现的第一件事情是 `docker build` 居然要两分钟 (╯’ – ‘)╯︵ ┻━┻
|
||||
|
||||
磨刀不误砍柴工,先把本地 Go 环境和依赖装上,把 SSL cert 挪到该放的地方,带缓存的编译重跑就只需要不到一秒了。
|
||||
|
||||
![[2023-11-18 22-52.png]]
|
||||
|
||||
### Attack 1
|
||||
|
||||
第一道题非常非常简单,跑起来之后看 HTTP 访问日志马上就看到了一条野生 SQL 语句 👀 原来要修的是 SQL 注入。看看代码,main 里面定义了这个 `/shop/items/:item` 节点的函数是 DescribeItem(),把整个函数拷给 GPT4 重写拿分()
|
||||
|
||||
![[2023-11-18 22-56.png|GPT4 不知道今天帮了我多少忙]]
|
||||
|
||||
### Attack 2
|
||||
|
||||
第二道题我觉得是这五道题里面最难的,因为攻击方法并不明显。整个日志看起来都非常正常,我为了让日志看起来更整洁还隐藏了签名验证的部分,日志里实现的操作都是用户正常使用会用到的,管理员创建了几个商品,删了两个商品,用户读了一次商品列表,然后买了一件,感觉没有哪里不对。
|
||||
|
||||
![[image_2023-11-18_23-47-54.png|非常正常的日志]]
|
||||
|
||||
看了超级超级久还没发现问题,但是把签名打出来发现了不同请求的签名长度不一样,所以猜猜可能是签名验证的问题就去检查了一下签名验证的代码,然后发现了这堆鬼东西:
|
||||
|
||||
![[2023-11-18 23-01.png|他们自己实现了一个 ASN 验证,后面是五百行的我都看不懂的纯正密码学]]
|
||||
|
||||
不懂就不要随便自己实现加密算法啊啊啊 (╯’ – ‘)╯︵ ┻━┻
|
||||
|
||||
密码学真的有太多我看不懂的理论数学,所以我也看不出他们的实现有没有 Bug,但是总之先试试让 GPT4 用标准库重写一遍 VerifyASN 吧... 重写完重跑一遍居然就真的就过了 🌚
|
||||
|
||||
## 总结
|
||||
|
||||
总之五道 Defense 题全都解出来啦,然后看时间不够快赶不上 One Among Us 的手工活动就赶快走了。感觉很开心!这是我参加的第一个有 Defense 题型的 CTF,感觉是和 Web 题完全反过来的题型。Defense 少见可能是因为这种题比较难封沙盒吧,不过第一次知道原来像我这样的平凡全栈技能也能在 CTF 上起到作用。
|
||||
|
||||
原本登记了这次比赛只是因为 UofTCTF 他们组缺人,去之前还觉得自己一年没打过 CTF 了说不定一道题都做不出来呢,结果发现并没有全都忘掉也很开心。居然比同组的那些 UofTCTF 活跃队员分还高一些(不过也是因为 Defense 题更简单啦)总之虽然已经退坑了但是偶尔打打 CTF 还是很好玩的!
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Maimai 街机音游逆向!
|
||||
subtitle: 从拆包到脱壳到魔改遇到的各种坑
|
||||
tags: [ctf]
|
||||
---
|
||||
|
||||
不久之前某位朋友拿到了最新最热的 maimai DX 某版本的 .app 镜像,然后和ta一起折腾了几天拆包脱壳之后终于成功启动了!在这里简单写一下解包过程和学到的事情,作为记录也作为一个教程吧,希望之后尝试解包的人不需要踩同样的坑了。
|
||||
|
||||
## 0x1 解压 .app 包
|
||||
|
||||
首先,朋友拿到的文件是一个 .app,通常在官方渠道中通过 U 盘或者网络下发给官方的街机框体,这也是所有 Sega 游戏都在用的传统格式。解压起来也已经有工具和教程。首先 .app 是使用 AES128 加密的,是一个对称密钥加密算法,然而已经有人帮忙把真正街机上的密钥复制出来了,接下来就是按教程解密啦,工具和教程都在[这个仓库](https://gitdab.com/SEGA/sega/src/branch/master/tools/Filesystem)里面。
|
||||
|
||||
解密之后是一个两层的 VHD 文件。第一层似乎文件元信息是坏的,大部分硬盘工具分区工具解压工具都打不开,不过试了各种方案发现在 Linux 上用 qemu-nbd 可以挂载,虽然会报错。挂载用了下面那一串指令,之后把 /mnt 里面的第二层 VHD 拷出来就可以直接用 7zip 解压了。
|
||||
|
||||
```sh
|
||||
sudo modprobe nbd
|
||||
sudo qemu-nbd -c /dev/nbd0 "outer.vhd"
|
||||
sudo mount /dev/nbd0 /mnt
|
||||
# /mnt 里面应该有 internal_{N}.vhd
|
||||
```
|
||||
|
||||
下面分别是两层 VHD 的文件头和 file 信息,`conectix` 开头的是第二层,`eb5290 NTFS` 开头的是第一层(以下用旧版中二节奏举例):
|
||||
|
||||
![[2023-11-30 10-04.png]]
|
||||
|
||||
qemu-nbd 加载了第一层之后连 fdisk 都是懵的,即使是这样乱来也能挂载上也真是好神奇:
|
||||
|
||||
![[2023-11-30 10-08.png]]
|
||||
|
||||
## 0x2 Crackproof 脱壳
|
||||
|
||||
解开 .app 包之后脱壳反而是最难的步骤,因为没有找到任何针对 SEGA 街机的脱壳教程(所以我才在写这篇教程!)解压好文件之后发现直接运行会闪退,为什么呢?用文件识别工具 [DIE (detect it easy)](https://github.com/horsicq/Detect-It-Easy) 检测一下发现主程序 Sinmai.exe 被一个叫 HyperTech Crackproof 的工具加密过 (aka. “Htpac”)。
|
||||
|
||||
![[2023-11-30 10-15.png]]
|
||||
|
||||
啊但是 Sinmai.exe 明明就只是一个 Unity 启动器,Unity 的话所有游戏的启动器都是一样的,让每个游戏不同的是 {Game}\_Data 里面的 dll 文件,所以我直接去下了一个[开源 GTA 圣安地列斯](https://github.com/GTA-ASM/SanAndreasUnity)然后把启动器拷出来改名成 Sinmai.exe 就可以了!
|
||||
|
||||
![[2023-11-30 12-23.png]]
|
||||
|
||||
...然后还是没法启动,怎么回事呢?用 `diec` 扫描一下目录里面的所有 exe 和 dll 发现还有四个文件是加密过的:
|
||||
|
||||
* amdaemon.exe
|
||||
* Sinmai\_Data/Managed/Assembly-CSharp.dll
|
||||
* Sinmai\_Data/Plugins/Cake.dll
|
||||
* Sinmai\_Data/Plugins/amdaemon_api.dll
|
||||
|
||||
查了一下,首先我看到 SEGA 工具库里面有一个叫 [DecryptCrackproof](https://gitdab.com/SEGA/sega/src/branch/master/tools/Crackproof/DecryptCrackproofExe64) 的工具,感觉这个尝试起来是最简单的,所以就先用它试了一下。这个工具确实自动解出来了 amdaemon.exe,但是剩下三个 DLL 都失败了,报错说 CRC32 算法的长度 out of range of valid values。
|
||||
|
||||
![[2023-11-30 10-31.png]]
|
||||
|
||||
看着这个报错,第一眼觉得应该是这个算法实现的问题,毕竟从 stack trace 可以看出传给 CalcChecksum 的参数是 UInt32,而 CRC32 函数接收的参数是 Int32... 会不会是长度超了 Int32 的最高值呢?所以就用 DotPeek 反编译、导出项目重新编译再 Debug 饶了一大圈,然后发现并不是长度超过 Int32 最高值,而是超过了整个文件的长度... 而请求长度又是一串非常不标准的计算算出来的,看来应该是 HyperTech 他们换了加密算法,并不是改一两行就能修好的。
|
||||
|
||||
![[2023-11-30 12-38.png]]
|
||||
|
||||
接下来有点没有头绪,到处查查也没有查到有用信息,有一个 [iatrepair](https://github.com/rakisaionji/iatrepair) 仓库的脱壳思路是开一个 QEMU 有 2GB 内存的 VM 里面跑游戏,等游戏加载完之后把整个系统的内存 dump 出来再解包分析... 感觉这个方法好灵车,而且这个加密也会检测系统是不是 VM。
|
||||
|
||||
接下来去群里问了问,群友给我了一份街机系统上会有的 odd.sys 驱动,用 OSRLoader 加载进系统就可以启动游戏了,虽然是黑屏而且不能正常启动 amdaemon。原本以为解密代码在驱动里面,所以先用 IDA Pro 打开驱动看看能不能提取出来,但是发现似乎只有一些 hashing 的 PID 验证和字符串处理,返回一个处理过的字符串,这样看来这个驱动应该是让用户进程上传识别信息来生成真正解密密钥的工具。
|
||||
|
||||
![[2023-11-30 12-36.png]]
|
||||
|
||||
但是感觉要反汇编找到真正解密的代码再重新实现出来有点太复杂了... 还是试试直接从内存里面找吧。
|
||||
|
||||
下载一个 CheatEngine 用来查看内存,然后启动游戏。刚开始发现 CheatEngine 的调试器挂到 Sinmai 进程上会报错,上网查了一下说可以试试在设置的调试器选项里开 DBVM 内核驱动模式,再把 Extras 里面的用内核模式浏览内存选上,然后果然就可以挂了!
|
||||
|
||||
![[2023-11-30 12-45.png]]
|
||||
|
||||
接下来打开内存视图,先看了一遍 Memory Regions 里面标注的 DLL 名,但是似乎没有我想要找的 DLL 的样子。那怎么办呢?看看有没有只属于这个 DLL 的可识别信息吧,用 hexed.it 打开上一代别人解包好的 Assembly-CSharp.dll 发现文件末尾有一串元信息,写着 "InternalName Assembly-CSharp.dll",每个字符中间间隔着一个 NULL \\0 字符。
|
||||
|
||||
![[2023-11-30 12-59.png]]
|
||||
|
||||
所以我在 CE 内存视图里面搜这段文字然后就找到了!正好只有一个匹配结果。然后记录一下最左边的地址,打开内存区域视图找到这个地址所属的区域,右键把整个区域存下来,然后用 hexed.it 打开把 CE 的文件头去掉(截到 DLL 文件头那里)再把文件尾补到整 32 位,保存改名成 DLL 就可以了!
|
||||
|
||||
![[2023-11-30 13-02.png]]
|
||||
|
||||
之后就只差 Cake.dll 和 amdaemon_api.dll 了,可惜这两个我用同样的方法没有解出来。amdaemon_api 在游戏里并没有加载,而 Cake 是一个包含了依赖的 dll,被拆成了很多不同的内存区域导致让它完整结合回去比较麻烦,但是这两个文件在不同版本中几乎没有改动,所以就直接拿上一个版本别人脱好壳的用了(一般会放在 segatools 文件夹里一起发出来)
|
||||
|
||||
## 0x3 魔改
|
||||
|
||||
可惜脱完 dll 壳并不是直接就能玩,还要去掉一些街机验证。因为 maimai 是一个 Unity 游戏,是用 C# 写的,而众所周知 .NET 运行时的结构和 Java 差不多都是反编译出来几乎直接是源码的,所以魔改思路就是把 Assembly-CSharp 反编译成源码,改好所有的代码报错,改掉验证然后再编译回去啦。
|
||||
|
||||
![[2023-11-30 13-53.png]]
|
||||
|
||||
...结果还在修报错的时候发现朋友已经魔改完了,所以就先用别人的啦,具体需要做哪些魔改等下次拿到最新最热数据再研究好了 qwq
|
||||
|
||||
## 0xF 总结
|
||||
|
||||
总之这是我第一次做 Windows 二进制程序脱壳,感觉有别人写好的文档和群友帮忙也没有想象的那么难(以前打 CTF 从来都不敢碰 binary 题的呜呜呜)虽然是朋友先解出来魔改完了有点扫兴,但是学到了很多脱壳技巧还是很开心的!现在就差给 [AquaDX](https://github.com/hykilpikonna/AquaDX) 加服务器支持了...(下次再写 qwq
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: 示波器破解移动光光猫
|
||||
title_image: "IMG_20240730_100300.avif"
|
||||
subtitle: 跑不掉了喵~
|
||||
tags: [ctf]
|
||||
---
|
||||
|
||||
今天去 syama 家帮忙破解了移动光光猫 🐱
|
||||
|
||||
因为笔记本太重了,syama 想要在外面用 iPad 远程桌面直连到家里的笔记本上工作,但是移动光猫有防火墙 ipv6 没办法直连,而且用户配置界面并没有办法关掉防火墙,所以想要找到 CMCCAdmin 超管密码。
|
||||
|
||||
通常来说超管密码直接找宽带师傅要或者找移动内鬼查就能查到,但是这台是公寓提供的集客宽带所以没办法联系师傅,内鬼也查不到密码。
|
||||
|
||||
去网络论坛上看了其他人的破解方法,一月份的时候似乎还可以通过 192.168.1.1/hidden_version_switch.html 之类的网页后门直接开启 telnet,但是试了很多全都被堵住了。看来软件是没办法了,拆机看看
|
||||
|
||||
![[IMG_20240728_190519.avif|拆机后的完整布局]]
|
||||
|
||||
光光猫型号是 SK-D747,拆机之后看到了这个 PCB 的完整布局,中间是铠侠的 128MB E2PROM,旁边有八个测试孔,左侧是光纤接入口(系统里是一个 PCIe 设备...??)右边应该是 CPU 和 DDR3 DRAM,上边是两个 LDO 分别转 3.3V 和 5V,右上方还有一些空的焊盘应该是高配版多出来的网口。
|
||||
|
||||
![[IMG_20240730_100300.avif|发现了 E2PROM 芯片旁边的八个测试引脚]]
|
||||
|
||||
在 E2PROM 旁边的这八个测试孔,四个焊了排针,四个没焊。通常光猫上都会留用来测试的 UART 串口,但是串口只能够用到 TX RX GND 三条线,TX 用来向对方发送,RX 用来接收,GND 作为判断传输电压的参照物。排针上显然并没有文档,没有标记每个引脚代表什么,还可能是 RS232,I2C,或者 SPI 也说不定,那要怎样判断每个测试孔代表着什么呢?
|
||||
|
||||
先断电用电表测试线路接通的模式戳一戳好了,因为一条线路的所有焊点都连在一起,所以只要确认了一个点就可以找到所有了。首先 GND(地线)是最容易找到的,因为元件的外壳通常就会接到 GND。把电表的一端点到 USB 口的金属外壳上,另一端轮流点一下每个测试孔就能看出从上面数的口 0、4、7 都是接地。
|
||||
|
||||
接下来还剩五个不确定的孔,通电测一下电压好了。孔 1 是 5V,孔 5 是 3V,6 是 0.074V。只看电压其实不是很有头绪呢,把照片发到嵌入式群里,群友猜 5 6 分别是 TX 和 RX,但是插上串口工具发现并没有输出,反过来也没有。
|
||||
|
||||
![[IMG_20240730_095534.avif|电表针好长感觉不注意就会把什么戳短路]]
|
||||
|
||||
突然想到,既然之前买了示波器就试试用示波器看看这些孔的波形好了!虽然之前没有用过但是也不会太难理解吧?打开包装有一个主机和两根测笔,像电表一样,那一定是在测量这两根针之间的波形吧?一根戳上 GND 另一根戳上测试点,点一下自动... 不对啊,看上去无论点哪里都是一个正弦 ~ 波?这看起来也不像是时钟啊?
|
||||
|
||||
...然后把照片发给群友问问,发现我完全理解错了(悲)测量到的波形也只是无线电噪音
|
||||
|
||||
实际上示波器的每根测笔是单独的波形,有两条线,侧边的夹子是电压的参照,而测针是相对的信号。读结果的时候也理解错了,虽然看上去是很大幅度的正弦波,但是右边显示着只有 5mV/格... 也就是说看到的这个波形上下差距一共不到 10mV,对于任何信号传输都太小了。居然这么小的电压区别也能测量出来哇
|
||||
|
||||
学会使用示波器之后把示波器夹子接地,针点到每个引脚,调到 1V/格 的显示比例,发现脚 2 是稳定 3V,脚 3 有零到 3V 的 TTL 高低电平切换,由此推断 2 3 才是 TX 和 RX。这次接上 UART 终于看到日志输出了!
|
||||
|
||||
![[2024-08-01 12-45.png|用示波器看到了上下切换的数据电平,高低电压分别代表 1 和 0]]
|
||||
|
||||
接上之后就交给 menci 了。重启路由器,终端打了很多日志,似乎是 uboot 在启动被运营商魔改的 OpenWRT。等开机结束之后导出日志,在里面搜索 "pwd" 找到了 root 密码 🌚 真安全。登录进 root 之后去读文件 /userconfig/cfg/db_user_cfg.xml 就拿到了超管密码,然后我累累睡睡,menci 念一串魔法配好了桥接和公网 v6 直通,好厉害
|
||||
|
||||
![[IMG_20240730_144448.avif|躺在床上听着魔法少女 menci 调网咒语 ASMR 睡着了]]
|
||||
|
||||
配网相关的事情可以右转去读读 [menci 的博客](https://blog.men.ci/),虽然我也想学学配 ipv6 但是这两天通宵爆肝实在没有力气啦,等加拿大 Bell 有了 ipv6 再学学好了。
|
||||
|
||||
总之这次学会了用示波器很开心!~~会用之后就看到什么都想用示波器点一点了~~
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: 世界计划 缤纷舞台!feat. IL2Cpp
|
||||
title_image: crop.png
|
||||
subtitle: 从零开始解密 pjsk 的网络请求
|
||||
tags:
|
||||
- ctf
|
||||
- 教程
|
||||
---
|
||||
玩了这么久プロセカ,有点想分析一下游戏的排行数据,做一些自动化,比如让自己一直停留在整两百名之类的。这种手游抓抓包模仿请求一定不会很难吧——装上 Reqable 连上模拟器抓抓包偷吃几块饼干之后发现,请求负载和返回内容都依然是乱码...
|
||||
|
||||
![[Screenshot 2024-10-24 062606.png|只偷吃到了饼干渣]]
|
||||
|
||||
看到乱码的第一反应是编码不对,或者过了某种基础混淆,总之先丢给 CyberChef 看看魔法糊糊器能不能把答案糊出来,发现不行。去群里问问之后 Menci 和我说是 AES 加密过的,还告诉我了加密密钥在 APIManager 里面,那就试着找找吧
|
||||
|
||||
## 1 从拆开 APK 开始
|
||||
|
||||
因为プロセカ的客户端能够解密,那么解密密钥一定就在客户端里。首先下载了プロセカ 4.0.0 的 XAPK 包。所谓的 XAPK 其实就是把多个 APK 压缩在一起啦,其中一个包含了所有架构共享的文件,另一个包里放了 arm64 平台专属的二进制(虽然这个游戏好像还没有编译过除了 arm64 以外的平台,不知道为什么还要这样分包)
|
||||
|
||||
解压缩之后单独用 apktool 把两个安装包的文件拆出来看一看吧。目录结构看上去就是一个一般的 IL2Cpp Unity 项目,没有什么特别的。编译过的游戏二进制在 lib/arm64-v8a/libil2cpp.so 里面,然后运行它、让它能够和 C# 那边的程序对接需要的元信息在 assets/bin/Data/Managed/Metadata/global-metadata.dat 里面。
|
||||
|
||||
![[2024-10-24 15-24.png|两个重要的文件]]
|
||||
|
||||
如果想要快速找到代码的话,只看 IL2Cpp 编译好的的二进制肯定不行。这个文件里一共有 44 万个没有名字的函数指针,IDA 把这些分析出来都花了快一个小时,从这里一个一个找肯定是不行的。所以我搜索了一下 IL2Cpp 反编译,找到了 Perfare/IL2CppDumper 这个工具,试了一下发现报错说 "Metadata file supplied is not valid metadata file"
|
||||
|
||||
![[2024-10-24 16-06 1.png|正常的元文件的文件头]]
|
||||
|
||||
为什么呢?看了一下,一般的 global-metadata 的文件头是 0xAF1BB1FA,而我拿到的这个不是,也就说明它很可能被加了壳。
|
||||
|
||||
## 2. 脱壳元文件
|
||||
|
||||
怎样解密呢?首先试了同一个作者的另一个工具 Zygisk-IL2CppDumper,是一个据说可以自动解密元文件的 Magisk 模块,但是构建好装上之后打开游戏发现什么都没有发生,说会解包到 /data/data 里面但是也没有,还是试试别的方法吧。
|
||||
|
||||
既然上次用 Cheat Engine 在电脑上从内存中脱壳了 maimai 的 Assembly-CSharp,我觉得应该也可以用同样的方式脱壳プロセカ的元文件,所以就下载了一份 Game Guardian 试了一下。打开游戏,挂载上 GG,内存搜索 "H AF1BB1FA" 之后马上就找到了!
|
||||
|
||||
![[Screenshot 2024-10-24 073615.png|搜索到了内存]]
|
||||
|
||||
把地址复制下来,去第四个 Tab 把地址粘贴进去之后点旁边的选择器选中这个区域的头到尾,导出成文件之后重命名成新的 global-metadata.dat 就可以了。之后重新试了一下用 IL2CppDumper 反编译就没有报错了!
|
||||
|
||||
![[2024-10-24 16-03.png|反编译成功了!]]
|
||||
|
||||
虽然还是有 "This file may be protected" 的报错,原本还以为 libil2cpp.so 也被套了壳,结果发现并没有,只是识别到了 JNI_OnLoad 预警性地报了一个错而已,也就是说已经成功啦。看了一眼 Dumper 输出的 `dump.cs`,里面确实有 APIManager,里面也确实用到了 AES 加密,但是因为实现的部分被转成二进制了,这里只能看到定义。
|
||||
|
||||
## 3. 找钥匙!
|
||||
|
||||
接下来就是找钥匙的环节,但是怎么找呢?如果要找到钥匙的话,大概需要看一下 APIManager 的代码实现,可是 `dump.cs` 里面只有空壳没有实现,可是只看 libil2cpp.so 又没办法知道 APIManager 在哪... 怎么办呢?看了一下 Dumper 仓库里面写的文档,看起来像是能和 IDA 联动让实现和空壳结合在一起的样子,那就下载一个 IDA 试试吧。
|
||||
|
||||
下载了 IDA 9.0,其实不太会用,我只懂得基础操作比如用 F5 切换反编译代码和汇编之类的,但是总之先打开二进制 libil2cpp.so 看看吧。刚打开的时候什么都没有,只有不到一百个函数,我还在怀疑是不是打开错文件了,但是等了十分钟又出现了很多函数,发现原来它只是还没有分析完... 可能 150MB 大的二进制对于 IDA 来说也太大了吧,完整分析完花了差不多一个小时,存的数据库有 2 GB 大 (╯’ – ‘)╯︵ ┻━┻
|
||||
|
||||
![[2024-10-24 16-26.png|好大文件]]
|
||||
|
||||
分析完之后依然没有函数名,全部都是 sub_1234567 这样的数字编号。看着 Dumper 的文档试着用 File > Script file 跑了一下那个 ida_py3 的脚本,选中了 script.json 之后函数名确实出现了!不过这只是第一步。
|
||||
|
||||
看了 `dump.cs` 里面的函数定义之后,我觉得 CreateCrypt 这个函数比较像是我们要找的,因为是一个静态函数,调用了之后会凭空造出来一个 FastAES 的实例,而 FastAES 需要传入两个钥匙才能创建实例,所以无论怎样这里都会用到钥匙吧?
|
||||
|
||||
![[2024-10-24 16-45.png|APIManager 的函数定义]]
|
||||
|
||||
看看反汇编的二进制代码发现确实用到了密钥。虽然变量全都被打乱了,但是从 FastAES 的构造器参数可以看出来 v3 v4 是 AES128 的两个钥匙 (key, iv),然后规格是 AES128,密文模式 1 (CBC),补格模式 2 (PKCS7)。那么这两个变量是从哪里来的呢?从代码里也能看到,v2 是从 v1 + 184 的指针地址读过来的,v3 是 v2 的值,v4 是 v2 的下一个值也就是 v1 + 192,而 v1 是 APIManager_TypeInfo 的地址。
|
||||
|
||||
![[2024-10-24 16-48.png]]
|
||||
|
||||
从 C# 的逻辑上想的话,TypeInfo 应该是一个类附带的常量或者变量类型的地方,从 `dump.cs` 里面可以看到它定义的变量指针偏差从 0 加到 8 加到 0x20, 0x28... 可是这里面没有 +184 = 0xb8 这个位置,为什么呢?
|
||||
|
||||
![[2024-10-24 19-27.png|APIManager 类的变量偏差最高到了 0x38]]
|
||||
|
||||
我在这里卡了超级久,然后看了一下构造器发现 +184 这个偏差原来是在调用构造器的时候动态写进去的,是代码里面没有定义的,也许是某种编译器优化吧。打开 `APIManager$$.cctor` 构造器首先发现的是它写入了指向 +184 的地方 (L23 行),写入的是 v3 的值。向前读可以看到 v3 就是 v1,是一个空的 16 字节数组,然后把 v2 的数据复制到了 v1,而 v2 是 `Field__` 开头的这一长串。
|
||||
|
||||
![[2024-10-24 19-32.png|cctor 构造器的反编译代码]]
|
||||
|
||||
回到 `dump.cs` 里面查找这个字段,发现它是一个编译器生成的只读项,上面下面也有类似的东西,看这个 "Static Array Init Type Size" 的话这些应该就是代码里用到的数组常量被转移到的位置了。但是这一长串的十六进制似乎只是某种指针,数值似乎不在这里——因为如果是数值的话,数组的长度对不上——我们要找的 AES128 密钥是 128 位也就是 16 字节,但是这一长串每个都有 32 字节,和前面的 Array Size 也对不上。那么数值在哪呢?
|
||||
|
||||
![[2024-10-24 19-37 1.png|十六进制 offset 在编辑器里五颜六色]]
|
||||
|
||||
看到后面五颜六色的 offset 那里写了注释 "Metadata offset",我就在想难道它会在 global-metadata 里面吗?用 hexed.it 打开了这个文件然后把 offset 复制到 Go to 栏里面转向,发现那里真的有一个 16 位的钥匙!接下来又按同样的方式找到了 16 位的 iv,整个 AES 加密就被破解啦(在这里打一下码,需要的话看完这篇肯定就能够自己找到了 quq)
|
||||
|
||||
![[2024-10-24 20-03 1.png|找到了 16 位的钥匙]]
|
||||
|
||||
## 4. 可以解密了!
|
||||
|
||||
接下来就是解密的过程了,把请求内容和返回内容都导出成文件,再随手用搓两行 python 脚本就可以解密啦:
|
||||
|
||||
```python
|
||||
import sys
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import unpad
|
||||
from pathlib import Path
|
||||
|
||||
aes = AES.new(b"- key here qwq -", AES.MODE_CBC, b"- iv here meow -")
|
||||
dec = lambda x: unpad(aes.decrypt(x), AES.block_size, style='pkcs7')
|
||||
|
||||
[f.with_suffix(".dec").write_bytes(dec(f.read_bytes()))
|
||||
for f in Path(sys.argv[1]).glob("*")]
|
||||
```
|
||||
|
||||
解密之后似乎是一个 pjsk 自己的二进制序列化格式,看起来还需要一些时间研究怎样把它转换成 json 呢,不过今天就到这里好啦,累累睡睡(x)
|
||||
|
||||
![[2024-10-24 21-02.png|解密之后的明文]]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: 《A Minecraft Movie》影评
|
||||
subtitle: 桂桂评分:★★☆☆☆ 2/5
|
||||
title_image: title.png
|
||||
tags:
|
||||
- 电影
|
||||
category: 电影
|
||||
---
|
||||
|
||||
Minecraft 电影刚上映,虽然已经知道了评价不太好,但是毕竟是陪伴了我 12 年的游戏还是第一天去看了,结果不出意料让我有些失望。
|
||||
|
||||
首先说优点,我觉得对游戏机制还原得还不错——水桶速降、鞘翅烟花加速、末影人的视线仇恨、铁傀儡仇恨、喂两个动物吃食物会迷之冒出小动物(?)把原本游戏里这些不合逻辑的奇妙机制都展现出来了。
|
||||
|
||||
缺点的话,可能就是「演出感」太浓了,感觉主角团智商完全没上线。很多困境都是只要留心就不会遇到的,也有很多次绕了大圈去解决很琐碎的事情。比起「遇到困境之后想出了解决方案」更像是「为了展示一个特定的解决方案而创造了困境」... 很多解决方案都是比起实用性更在意演出效果和 meme 程度。
|
||||
|
||||
作为一个 Minecraft 电影我可以说它既没有 Mine 也没有 Craft... 就比如,整个电影结束没有一个人造出工作台或者把摆在那里的工作台带走就好过分啊?离开村子起飞的时候还把武器掉下了,真的没有一个人想到之后可能会用到?之后果然就遇到了需要打架但是没有武器的场面,然后大家还完全没对武器丢了感到惊讶,就像从来就没有造过武器一样?然后主角 Henry 去翻宝箱找 Earth Crystal 的时候翻到了两把武器,却只拿了自己的,多余的也没给朋友拿上...
|
||||
|
||||
感觉就像是一个解谜地图的作者在直播玩自己的解谜,如果已经知道了接下来要怎么解为什么要动脑子?但是不动脑子的话,预先设想好的解法在观众眼里就变成了最繁琐最莫名其妙的。
|
||||
|
||||
还有就是剧情实在太平淡了,超耿直毫无转折完全可以预测接下来会发生什么,也几乎没有人物塑造。感觉导演的优先级是 玩梗 > 展示游戏机制 >>> 角色成长 > 冒险解谜。
|
||||
|
||||
不过看到结尾还是有些感动。结尾主角团拯救了 Minecraft 世界之后把从这里获得的成长和知识带到了现实中,解决了现实中各自的困境... 我觉得这部电影最真实的一幕可能就在这里。
|
||||
|
||||
我也是从搭建建筑,到设计起红石电路,再到写起自己的插件和模组,最后像电影里一样走出 Minecraft 把这些技能带到现实,给我了有想法就自己去创造的勇气和能力——这才是我最喜欢 Minecraft 的原因,也是这个电影试图表达的,即使表达得不是很好。
|
||||
|
||||
总之我给 2/5 星,也许适合关掉脑子看
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: 《欢迎来到駒田蒸馏所》感想
|
||||
subtitle: 关于威士忌蒸馏所、以及在平凡工作中找到热情的故事
|
||||
title_image: title.png
|
||||
tags:
|
||||
- 电影
|
||||
- 剧透警告
|
||||
category: 电影
|
||||
---
|
||||
昨天看了电影《駒田蒸馏所へようこそ》,原来威士忌是这样做出来的,好奇妙
|
||||
|
||||
除了威士忌以外,我好喜欢这部电影对理想的呈现——不是追求一个目标的过程,而是这个目标最初的形成,是从一份无聊的工作中找到意义的过程。
|
||||
|
||||
男主刚开始很迷茫的样子,在新闻社工作却并不喜欢新闻,觉得是被分配了麻烦的活赶快做完算了,结果去采访甚至搞错了采访对象是哪家蒸馏所。他羡慕别人一开始就知道自己想做什么,可是并不是这样...
|
||||
|
||||
![[vlcsnap-2025-04-16-19h44m43s419.png|用平假名查谷歌不太聪明喵]]
|
||||
|
||||
女主年纪轻轻就继承了家业并不是她最初想做的事情。她原本在艺校想做画师,但是蒸馏所地震让热销的威士忌「独楽」无法再产,她父亲因此过劳去世,计划继承家业的哥哥也离开了。原本已经准备停业了,但是她看着母亲的坚持很不甘心、想要让蒸馏所的大家回到以前的气氛,决定退学用剩下的原酒放手一搏,在大家的帮助下从零开始了解威士忌...
|
||||
|
||||
![[vlcsnap-2025-04-18-05h22m09s691.png|有些羡慕女主妈妈能允许她退学这样拼]]
|
||||
|
||||
随着男主逐渐了解女主这段经历,也许是被她复活独楽的执着打动,才找到了继续做新闻工作的动力——为了让更多人听到女主的执着和独楽的进展、为了请求群众帮忙找到合适的原酒——最终让一件不得不做的麻烦任务成为了自己的热情。
|
||||
|
||||
## 理想的诅咒
|
||||
|
||||
回到我的生活,原本以为自己想做什么已经非常明确了,但是现在快毕业了才发现自己比身边的人更迷茫,因为我真的好害怕自己会变成最开始男主的那个样子,只是为了生存做着自己不喜欢的事情...
|
||||
|
||||
想过申课程型硕士觉得肯定考完试就会把课程内容全忘掉,想过申研究型硕士又害怕导师给的研究题目我会不感兴趣,想过去大厂工作又担心自己对项目的想法和坚持会被上层忽视,创业小厂联系过我我又觉得像是痴人说梦,继续做自己在做的小众项目也赚不到钱。
|
||||
|
||||
但是看完这部电影之后,感觉我对未来没那么害怕了,因为也许那些一开始觉得麻烦无聊的工作,只要用心理解,也能在某个特别的方向上找到自己的热情和意义吧——也许课程型硕士的小组作业会成为把代办里一个人做不到的想法实现出来的契机,也许无聊的研究题目也能加些私货向喜欢的方向靠拢,也许去大厂即使想法被无视也能很快攒够资金去开现在开不起的大坑——感谢駒田蒸馏所解除了理想对我的诅咒。
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: PJSK 电影《壊れたセカイと歌えないミク》感想
|
||||
subtitle: 实际上是初音未来的消失大电影吧(?)
|
||||
title_image: title.png
|
||||
tags:
|
||||
- 电影
|
||||
- 剧透警告
|
||||
category: 电影
|
||||
---
|
||||
昨天晚上去看了 PJSK 电影「壊れたセカイと歌えないミク」,因为看到是特典的最后一天不想错过。这是我第一次看没有字幕的日语电影,居然大部分都听懂了!好开心,路上还超担心自己会看不懂的
|
||||
|
||||
感觉这整部电影完全是在致敬暴走老师的「初音ミクの消失」,到转折点ミク说出那句「ありがとう、そして、さよなら」我直接哭出来...
|
||||
|
||||
想起来初中一年第一次听到这首歌看到 MV 的时候,还不理解ミク是什么,真心被这首歌打动哭了超级久,真心以为这是ミク离别的曲子,还因此去补了其他ミク的曲子... 不久之后和 ACGN 社团学姐聊天才知道ミク并不是活在电脑中的有意识的创作人才觉得松了口气。啊啊 虽然黑历史但是好怀念
|
||||
|
||||
也许这个电影就是想要营造出这样一个世界吧,一个ミク真的活过来也真的会消失的世界。也许大人会觉得这个设定莫名其妙,但是抛开现实ボカロ软件的约束接受了这个设定之后确实是一个很感人的剧情
|
||||
|
||||
另外的话,我觉得作为看过剧情的 PJSK 玩家,能够看到自己喜欢的角色、存在于 live2d 和文字的想象中的角色在电影中动起来很开心。但是这也让剧情节奏变得怪怪的,因为 PJSK 有五个团,想要同时照顾喜欢每个团的观众的结果就是,每段剧情都会重复五遍... 不过最后有五段不同的 Live 看也满足了(原来死宅就是我
|
||||
|
||||
Live 和 Afterlive 的时候还有人带了物理荧光棒耶,好厉害,可惜昨天只有不到十个人没有气氛呢。
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: 《超人》影评
|
||||
subtitle: 桂桂评分:★★☆☆☆ 2/5
|
||||
title_image: title.png
|
||||
tags:
|
||||
- 电影
|
||||
- 剧透警告
|
||||
category: 电影
|
||||
---
|
||||
今天去看了新电影《超人》,觉得很好的设定被浪费了。
|
||||
|
||||
设定很棒,是一个关于超人的人性和成长的故事,是一个反思超人的责任、反思超人对一件有争议事情的对错私自下结论的权利的故事。但是这部电影完全没有表现出这个设定的深度,缺陷实在是太多了...
|
||||
|
||||
首先是反派,这次又是一个超级英雄电影里面常见的非黑即白的反派,不惜牺牲一个地球也要杀死超人,就仅仅是因为嫉妒吗?
|
||||
|
||||
而且如果是超能力者就算了,如果是哪个国家领导就算了,只是一个普通富人 CEO,下命令操控着几个能打过超人的超能力者还有超人的克隆人,CEO 命令别人毁灭世界也没人反思一下... 编剧给的解释是克隆人克隆傻了容易操控?那其他人呢?太偷懒了。
|
||||
|
||||
再看看主角这边,最开始闯了祸,没有经过任何谈判过程或者任何一方的知情就擅自用超能力阻止了一场战争,只因为他觉得阻止战争是正确的。这件事情新闻出来了当然大家都很反对,还因此和对象吵了架。那后来呢?并没有看到超人反思,反而是 CEO 自己揭露出这场战争就是他为了在舆论上 cancel 超人准备的阴谋...???接下来战争第二次重新开始的时候超人继续毫无顾虑地让朋友结束战争,毫无顾虑地瘫痪着对面的大军... 完全没有成长。
|
||||
|
||||
明明有这么好的设定,结果并没有让超人反思成长,反而是把原本模糊不清的事情揭露成非黑即白的阴谋,仿佛在和大家说超人从一开始就没做错... 🌚 太浪费了。感觉就像给导演了一道电车难题,然后导演说直接把电车传送走。
|
||||
|
||||
另外就是,感觉超人特写真的很好笑... 有巨大怪兽在纽约中心胡闹的时候特写超人在救怪兽脚底下的小松鼠... 整个地球在被撕裂、一整栋楼倒下来的时候特写超人在看起来很费力地撑起楼救下面一个还没开走车的阿姨... 仿佛就像超人在假设镜头外的人都免疫伤害外挂一样。而且类似的镜头至少重复五六次了,好无聊哦。
|
||||
|
||||
好笑的地方确实很好笑,超狗很可爱,战斗特效也很爽,但是我觉得就,如果它定位是喜剧然后把上述缺点再写得无厘头一点的话应该是很好的喜剧... 可惜因为它定位是一个认真讲故事的电影所以我只能给 2/5 星 ★★☆☆☆
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: GPT5 早安机器人
|
||||
subtitle: 一个下午就能搞定的小彩蛋?不存在的
|
||||
title_image: title.png
|
||||
tags:
|
||||
- 技术
|
||||
- LLM
|
||||
- NLP
|
||||
category: 技术
|
||||
---
|
||||
给 one-among-us 的后端机器人加了一个 gpt5 早安 bot!有逝者或者动画角色的生日庆祝、有什么の日的提示、还有创意御神籤和无厘头的忌宜消息 qwq
|
||||
|
||||
实现起来比预想要难,感觉想开启 gpt5 的脑洞让它输出随机的东西饶了好多圈圈。
|
||||
|
||||
## 1. 随机数?42!
|
||||
|
||||
想要一些无限脑洞的无厘头句子伴随今日御神籤,但是众所周知 LLM 和我们一样没法凭空想出随机的东西——让它说一个随机数它大概率会说 42... 想让它说一段随机无厘头文学也大概率会随机得很固定。
|
||||
|
||||
![[2025-08-24 06-58.png|至少没有深思七百五十万年]]
|
||||
|
||||
以及发现 GPT5 API 似乎已经不支持调温度和 top-p 之类的参数了,但是即使回到 GPT4 把温度拉高也没好多少。
|
||||
|
||||
所以觉得还是需要一些真随机,决定在生成 prompt 的时候喂给它一些真正随机的关键词,用这些词造句!手动想了几个「书架 企鹅 比利时 黑板报 雨伞」喂给它发现效果还很不错 qwq
|
||||
|
||||
但是怎样自动生成这些随机关键词呢?第一反应是找一个大词库选出高出现频率的名词。找到最合适的也许是结巴词库,既有出现频率又有词性,简单过滤一下之后... 发现随机出来的词都不太适合日常使用的样子,让它随机了十个给我了下面这些:
|
||||
|
||||
> 运量,细胞膜,型谱,危险性,军火,差点,膳食,蒜,游历,蠹
|
||||
|
||||
怎么说呢,感觉都太死板了,我想要更可爱一点的词,比如毛绒猫咪订书机抹茶召唤兽之类的... 试试用脚本过滤出可爱的词好了。
|
||||
|
||||
## 2. 可爱相关的词完全不可爱!
|
||||
|
||||
想找到可爱的词的话,首先试了用谷歌开源 gemma3 1b 的 embedding 相似度。Input embedding 是一个向量,代表了大模型对某个输入的理解,大概可以理解为「你听到一个词的时候哪些脑细胞会亮」之类的测量吧。因此只要两个输入向量更相似,它们在某个层面上就会更接近。比如猫与狗的距离比公司或者订书机更接近:
|
||||
|
||||
![[2025-08-24 07-36.png|聪明猫咪会梦见会社订书机吗]]
|
||||
|
||||
所以我在想,如果找到和「可爱」向量距离最接近的词是不是就能找到可爱的词了呢?就把整个结巴数据集用这个脚本跑了一遍,感谢 H100 不到十分钟就跑完了,但是发现和想象不太一样... 和可爱接近的大部分是形容词,接近的名词也不那么可爱:
|
||||
|
||||
![[2025-08-24 07-41.png|你告诉我和可爱最接近的名词是性感,下一个是时尚...]]
|
||||
|
||||
经群友 Etaoin 推荐试了一下中文表现更好的 bge-m3 embedding,效果确实好很多,相似度最高的有「美女 笑容 小朋友 样子 女孩 粉色 迷人」之类的,但是这些依然不是我想要的... 我想要的是可爱的名词而不是「可爱」的同义词。
|
||||
|
||||
然后和群友聊怎么办的时候突然想到,可以直接用 emoji 呀!大部分 emoji 代表的东西都是公认很日常的东西而且都比较可爱。下载了一个 emoji 数据库,过滤掉肤色和性别变种的重复项,再忽略掉包含「人」的就差不多了。过滤掉的这些总共占 67%,奇妙。
|
||||
|
||||
![[2025-08-24 08-03.png|emoji 大杂烩]]
|
||||
|
||||
然后效果就很不错啦,再随机一次就是「棕色方块, 卷轴, 冈比亚, 爆炸, 五点, 按键 8, 左下箭头, 蒙特塞拉特, 秘鲁, 世界地图」又随机又常用也很开脑洞 qwq
|
||||
|
||||
## 3. 小泉花阳生日是什么时候来着?
|
||||
|
||||
原本觉得 gpt5 这种参数多到数不清的模型肯定有记住一些动画角色的生日的,就让它自由发挥了,然后发现一个都没答对(悲)
|
||||
|
||||
![[Screenshot 2025-08-24 081819 (1).jpg|原来你是缪斯厨]]
|
||||
|
||||
看来生日也要让我的程序写在 prompt 里面了。首先找了一下二次元生日数据库发现同一天的角色生日实在是太多太多了,最多的一天总共有 1885 个生日(在 7 月 7 日)
|
||||
|
||||
有这么多生日的话全都喂给 llm 让它选就太贵了,毕竟每个输入字符都是要钱的。而且这里面大部分都是没人听说过的配角,也没有太大意义。怎么办呢?
|
||||
|
||||
查了查找到了 https://bd.fan-web.jp/ 这个网站,不仅有每天的角色生日还有投票功能!投票多的大概率是人气角色,正好是我需要的!所以就爬下来了,然后发布了一个[动态更新的 JSON 数据集](https://github.com/hykilpikonna/AnimeBirthdaysDataset)。除了角色生日还有声优、花语、什么の日、历史事件之类的,正好一起喂进去了,生日选了投票最多的五个让模型自由发挥选一个最有趣最可写的人写祝福效果还不错。
|
||||
|
||||
## 4. 总结
|
||||
|
||||
然后就有了这个每天早上的早安消息!感觉真的达到了想要的效果
|
||||
|
||||
![[Screenshot 2025-08-24 062639.png|部署之后的第一条自动生成的早安消息]]
|
||||
|
||||
不过想让模型打开脑洞随机写点什么真的不容易呀,原本想一个下午做完的小彩蛋变成了两天的项目(悲),明明 one-among-us 还有其他代码更需要我付出精力的... 不过学到了好多,花的时间也不后悔啦
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
title: 部落格索引
|
||||
subtitle: 按分类、标签检索
|
||||
category: 置顶
|
||||
pinned: 1
|
||||
---
|
||||
|
||||
按分类检索:
|
||||
|
||||
<BlogIndex/>
|
||||
|
||||
按主题检索:
|
||||
|
||||
<BlogIndex mode="categories"/>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
routeName: string;
|
||||
navBookmark?: string;
|
||||
}
|
||||
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import '@/css/global.sass';
|
||||
import '@/css/animations.sass';
|
||||
import Navigation from '@/components/Navigation.vue';
|
||||
|
||||
const { title, routeName, navBookmark } = Astro.props;
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="Aza's Corner" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title ? `Aza - ${title}` : 'Aza - Home'}</title>
|
||||
|
||||
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script is:inline src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js" integrity="sha512-uto9mlQzrs59VwILcLiRYeLKPPbS/bT71da/OEBYEwcdNUk8jYIy+D176RYoop1Da+f9mvkYrmj5MCLZWEtQuA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
</head>
|
||||
<body>
|
||||
<Navigation client:load currentPath={currentPath} routeName={routeName} navBookmark={navBookmark} />
|
||||
<div id="app">
|
||||
<slot />
|
||||
<div class="footer-spacer"></div>
|
||||
</div>
|
||||
<style is:global lang="sass">
|
||||
@use "../css/colors"
|
||||
|
||||
html, body
|
||||
height: 100%
|
||||
margin: 0
|
||||
background: #f9f2e0
|
||||
|
||||
#app
|
||||
padding-top: 20px
|
||||
height: 100%
|
||||
max-width: 900px
|
||||
margin: auto
|
||||
display: flex
|
||||
flex-flow: column
|
||||
color: colors.$color-text-main
|
||||
text-align: center
|
||||
-webkit-font-smoothing: antialiased
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
|
||||
.footer-spacer
|
||||
height: 100px
|
||||
flex-shrink: 0
|
||||
|
||||
/* Load this last */
|
||||
#nav *
|
||||
transition: all .25s ease
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,7 @@ import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import Collapse from "@/components/Collapse.vue"
|
||||
import BlogIndex from "@/components/BlogIndex.vue";
|
||||
import Tag from "@/components/Tag.vue";
|
||||
import 'virtual:uno.css'
|
||||
|
||||
const app = createApp(App).use(router).use(i18n)
|
||||
.component('Collapse', Collapse)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FOF</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="four1">Four</div>
|
||||
<div class="oh">Oh</div>
|
||||
<div class="four2">Four</div>
|
||||
</body>
|
||||
|
||||
<style>
|
||||
body
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: "Microsoft YaHei UI", Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 20em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' \
|
||||
height='50px' width='50px'><text x='0' y='15' font-family='Avenir, Helvetica, Arial, sans-serif' \
|
||||
fill='lightgray' transform='translate(15, 15) rotate(-45)' font-size='10'>肆零肆</text></svg>");
|
||||
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
html
|
||||
{
|
||||
height: 100%;
|
||||
}
|
||||
.four2, .oh
|
||||
{
|
||||
margin-top: -300px;
|
||||
}
|
||||
.four1
|
||||
{
|
||||
margin-left: -100px;
|
||||
color: #ff8373;
|
||||
}
|
||||
.oh
|
||||
{
|
||||
color: khaki;
|
||||
}
|
||||
.four2
|
||||
{
|
||||
margin-right: -100px;
|
||||
color: cornflowerblue;
|
||||
}
|
||||
|
||||
</style>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import AboutView from '@/views/About.vue';
|
||||
---
|
||||
|
||||
<Layout title="关于" routeName="About">
|
||||
<AboutView client:load />
|
||||
</Layout>
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import BlogView from '@/views/Blog.vue';
|
||||
|
||||
const post = Astro.url.searchParams.get('post');
|
||||
const category = Astro.url.searchParams.get('category');
|
||||
const tag = Astro.url.searchParams.get('tag');
|
||||
---
|
||||
|
||||
<Layout title="记事本" routeName="Blog">
|
||||
<BlogView client:load post={post} category={category} tag={tag} />
|
||||
</Layout>
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import FriendsView from '@/views/others/Friends.vue';
|
||||
---
|
||||
|
||||
<Layout title="朋友们" routeName="Friends" navBookmark="Others">
|
||||
<FriendsView client:load />
|
||||
</Layout>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Home" routeName="Home">
|
||||
<div id="Home" class="fbox-center f-grow1">
|
||||
<div id="box">
|
||||
<div class="font-script-en">Azalea's</div>
|
||||
<div class="font-script-en bold">Road Less Traveled</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style lang="sass">
|
||||
#box
|
||||
font-size: 3em
|
||||
|
||||
#Home
|
||||
text-align: left
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
|
||||
interface MenuItem {
|
||||
name: string
|
||||
sub?: string
|
||||
img?: string
|
||||
recommend?: boolean
|
||||
original?: boolean
|
||||
id?: number
|
||||
}
|
||||
|
||||
interface MenuCategory {
|
||||
cat: string
|
||||
subtitle?: string
|
||||
items: MenuItem[]
|
||||
column?: number
|
||||
}
|
||||
|
||||
const menu: MenuCategory[] = [
|
||||
{
|
||||
cat: '🍖 猪肉',
|
||||
items: [
|
||||
{name: '玉米排骨汤', recommend: true},
|
||||
{name: '红烧蜜汁五花肉', sub: '+卤蛋', recommend: true},
|
||||
{name: '蒜香炸排骨'},
|
||||
{name: '椒盐排骨'},
|
||||
{name: '酱香排骨'},
|
||||
{name: '四川回锅肉'},
|
||||
{name: '蒜蓉粉丝蒸排骨'},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🍗 鸡肉',
|
||||
items: [
|
||||
{name: '土豆炖鸡腿', recommend: true},
|
||||
{name: '香烤鸡腿', recommend: true},
|
||||
{name: '可乐鸡翅'},
|
||||
{name: '照烧鸡翅'},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🥩 牛肉',
|
||||
items: [
|
||||
{name: '煎牛排'},
|
||||
{name: '牛肉粉丝汤'}
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🐟 海鲜',
|
||||
items: [
|
||||
{name: '煎三文鱼皮'}
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🥗 菜',
|
||||
items: [
|
||||
{name: '肉丁炒芹菜', recommend: true},
|
||||
{name: '干锅菜花', recommend: true},
|
||||
{name: '韭菜炒蛋'},
|
||||
{name: '红烧土豆'},
|
||||
{name: '葱花鸡蛋'},
|
||||
{name: '白菜炖粉条'},
|
||||
{name: '素炒绿叶菜', sub: '大白菜/小油菜'},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🍜 面条',
|
||||
items: [
|
||||
{name: '味噌叉烧豚骨面'},
|
||||
{name: '番茄牛肉面'},
|
||||
{name: '黑椒炒意面'},
|
||||
{name: '炒面', sub: '挂面/乌冬/意面/方便面'},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🍥 其他的',
|
||||
items: [
|
||||
{name: '鸡蛋火腿吐司', recommend: true},
|
||||
{name: '茶叶蛋'},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🍛 主食',
|
||||
items: [
|
||||
{name: '照烧肥牛饭', sub: '肥牛片/五花肉', recommend: true},
|
||||
{name: '咖喱饭', sub: '牛肉块/肥牛片/五花肉'},
|
||||
{name: '炒饭'},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🍰 蛋糕',
|
||||
subtitle: '(要提前几天预定哦! qwq',
|
||||
items: [
|
||||
{name: '提拉米苏', recommend: true},
|
||||
]
|
||||
},
|
||||
{
|
||||
cat: '🍸 饮料',
|
||||
items: [
|
||||
{name: '白桃奶油鸡尾酒', recommend: true, original: true},
|
||||
{name: '火龙果葡萄酒', original: true},
|
||||
{name: '水果宾治鸡尾酒'}
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const maxCols = 2
|
||||
const cols: MenuCategory[][] = Array.from({length: maxCols}, () => [])
|
||||
|
||||
const tmp = [...menu]
|
||||
tmp.sort((a, b) => b.items.length - a.items.length)
|
||||
|
||||
const colCounts = new Array(maxCols).fill(0)
|
||||
for (const cat of tmp) {
|
||||
const col = colCounts.indexOf(Math.min(...colCounts))
|
||||
cat.column = col
|
||||
colCounts[col] += cat.items.length
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxCols; i++) {
|
||||
cols[i] = menu.filter(it => it.column == i)
|
||||
}
|
||||
|
||||
let id = 0
|
||||
cols.forEach(col => col.forEach(cat => cat.items.forEach(it => it.id = id++)))
|
||||
---
|
||||
|
||||
<Layout title="菜单" routeName="Menu" navBookmark="Others">
|
||||
<div id="Menu" class="general-page">
|
||||
<div class="title">
|
||||
<h2>小桂桂的私房菜 菜单</h2>
|
||||
<div class="subtitle">在桂桂家里可以吃到这些哦</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
{cols.map((col, colI) => (
|
||||
<div class="column">
|
||||
{col.map(cat => (
|
||||
<div class="category">
|
||||
<div class="cat">{ cat.cat }!</div>
|
||||
<div class="subtitle">{ cat.subtitle }</div>
|
||||
<div class="items">
|
||||
{cat.items.map(item => (
|
||||
<div class="item" class:list={{recommend: item.recommend, original: item.original}}>
|
||||
<span class="number">{ item.id }. </span>
|
||||
<span class="name">{ item.name }</span>
|
||||
{item.sub && <span class="sub">({item.sub})</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../css/colors"
|
||||
@use "../css/responsive"
|
||||
|
||||
.columns
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
flex-wrap: wrap
|
||||
|
||||
.column
|
||||
flex-grow: 0
|
||||
max-width: 50%
|
||||
min-width: 180px
|
||||
white-space: nowrap
|
||||
|
||||
.category
|
||||
margin-bottom: 1em
|
||||
|
||||
.cat
|
||||
font-size: 1.2em
|
||||
font-weight: bold
|
||||
|
||||
.subtitle
|
||||
font-size: 0.8em
|
||||
margin-bottom: 0.5em
|
||||
color: colors.$color-text-light
|
||||
|
||||
.items
|
||||
.sub
|
||||
font-size: 0.7em
|
||||
|
||||
.item.recommend
|
||||
color: colors.$color-text-main
|
||||
|
||||
.item.original:after
|
||||
content: '原创'
|
||||
font-size: 0.8em
|
||||
color: #ec9139
|
||||
background: rgba(255, 200, 131, 0.4)
|
||||
border-radius: 5px
|
||||
padding: 0 5px
|
||||
margin-left: 10px
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import LifeView from '@/views/Life.vue';
|
||||
---
|
||||
|
||||
<Layout title="生活" routeName="Life">
|
||||
<LifeView client:only="vue" />
|
||||
</Layout>
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="更多" routeName="Others">
|
||||
<div id="Others" class="general-page">
|
||||
<div class="title">
|
||||
<h2>更多链接</h2>
|
||||
<div class="subtitle">欢迎点进来看看</div>
|
||||
</div>
|
||||
<div class="links">
|
||||
<a class="rlink" href="/kitchen-menu">🍳 小桂桂的私房菜 菜单</a>
|
||||
<a class="rlink" href="/friends">🎎 朋友们</a>
|
||||
<a href="https://cast.hydev.org">📹 公开直播间</a>
|
||||
<a href="https://status.hydev.org/">🔌 看看服务器们过得怎么样</a>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style lang="sass">
|
||||
@use "../css/global"
|
||||
@use "../css/colors"
|
||||
@use "../css/responsive"
|
||||
|
||||
.links
|
||||
display: flex
|
||||
gap: 10px
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
|
||||
a
|
||||
@extend .card
|
||||
@extend .clickable
|
||||
|
||||
color: colors.$color-text-main
|
||||
text-decoration: none
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
import PhotoView from '@/views/Photo.vue';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const photos = await fetch('https://p.aza.moe/photos').then(res => res.json());
|
||||
return [
|
||||
{ params: { id: undefined } }, // /photo
|
||||
...photos.map((p: any) => ({ params: { id: p.id } })) // /photo/:id
|
||||
];
|
||||
}
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="相册" routeName="Photo">
|
||||
<PhotoView client:only="vue" id={id} />
|
||||
</Layout>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import Layout from '@/layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<Layout title="我做的" routeName="Projects" navBookmark="Others">
|
||||
<div id="Projects" class="general-page">
|
||||
<div class="title">
|
||||
<h2>我做的</h2>
|
||||
<div class="subtitle">一些有趣的小项目</div>
|
||||
</div>
|
||||
<p>这里还在施工中...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
File diff suppressed because one or more lines are too long
@@ -95,16 +95,14 @@ export function parseExtensions(raw: string): string
|
||||
function collapseSection()
|
||||
{
|
||||
const e = findSectionEnd()
|
||||
const title = lines[i].substring(lines[i].indexOf(' ') + 1).replace(re.command, '')
|
||||
lines[i] = `<Collapse title="${encodeURIComponent(title)}">`
|
||||
lines.splice(e, 0, '</Collapse>\n')
|
||||
const title = lines[i].replace(re.hashes, '').replace(re.command, '').trim()
|
||||
lines[i] = `<div class="collapse-block"><h3 class="collapse-header clickable">${title}</h3><div class="collapse-content">`
|
||||
lines.splice(e, 0, '</div></div>\n')
|
||||
}
|
||||
|
||||
// Run all commands in markdown
|
||||
while (i < lines.length)
|
||||
{
|
||||
console.log(`Line ${i}`)
|
||||
|
||||
// Find commands
|
||||
const r = re.command.find(lines[i])
|
||||
if (r)
|
||||
@@ -113,7 +111,6 @@ export function parseExtensions(raw: string): string
|
||||
cmd = cmd.substring(5, cmd.length - 5).trim()
|
||||
|
||||
// Run cmd
|
||||
console.log(`Running command`, cmd)
|
||||
eval(cmd)
|
||||
}
|
||||
|
||||
@@ -131,6 +128,5 @@ export function parseExtensions(raw: string): string
|
||||
collapseSection()
|
||||
}
|
||||
|
||||
console.log(lines.join('\n'))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { reactive } from 'vue';
|
||||
import { BlogMeta } from "@/scripts/models";
|
||||
|
||||
export const globals = reactive({
|
||||
staticMeta: { tags: [], categories: [], posts: [] } as BlogMeta,
|
||||
});
|
||||
@@ -8,7 +8,8 @@ export const messages = {
|
||||
blog: '记事本',
|
||||
life: '生活',
|
||||
projects: 'Projects',
|
||||
others: '更多'
|
||||
others: '更多',
|
||||
photo: '相册'
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
|
||||
+33
-83
@@ -1,88 +1,38 @@
|
||||
import {createRouter, createWebHistory, NavigationFailure, RouteRecordRaw} from 'vue-router'
|
||||
import Home from '../views/Home.vue'
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
meta: {title: 'Home', nav: true},
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/new-home',
|
||||
name: 'New Home',
|
||||
meta: {title: 'Home'},
|
||||
component: () => import('../views/NewHome.vue')
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'About',
|
||||
meta: {title: '关于', nav: true},
|
||||
component: () => import('../views/About.vue')
|
||||
},
|
||||
{
|
||||
path: '/life',
|
||||
name: 'Life',
|
||||
meta: {title: '生活', nav: true},
|
||||
component: () => import('../views/Life.vue')
|
||||
},
|
||||
{
|
||||
path: '/blog',
|
||||
name: 'Blog',
|
||||
meta: {title: '记事本', nav: true},
|
||||
component: () => import('../views/Blog.vue'),
|
||||
props: route => (route.query)
|
||||
},
|
||||
{
|
||||
path: '/others',
|
||||
name: 'Others',
|
||||
meta: {title: '更多', nav: true},
|
||||
component: () => import('../views/Others.vue')
|
||||
},
|
||||
{
|
||||
path: '/kitchen-menu',
|
||||
name: 'Menu',
|
||||
meta: {title: '菜单', navBookmark: 'Others'},
|
||||
component: () => import('../views/others/Menu.vue')
|
||||
},
|
||||
{
|
||||
path: '/friends',
|
||||
name: 'Friends',
|
||||
meta: {title: '朋友们', navBookmark: 'Others'},
|
||||
component: () => import('../views/others/Friends.vue')
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'Projects',
|
||||
meta: {title: '我做的', navBookmark: 'Others'},
|
||||
component: () => import('../views/others/Projects.vue')
|
||||
},
|
||||
{
|
||||
path: '/color',
|
||||
name: 'ColorPicker',
|
||||
meta: {title: 'Color Picker'},
|
||||
component: () => import('../components/color/ColorPickerTest.vue')
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export function pushQuery(query: {[id: string]: string | null}): Promise<void | NavigationFailure | undefined>
|
||||
export function pushQuery(query: {[id: string]: string | null}): void
|
||||
{
|
||||
const queries = {...router.currentRoute.value.query ?? {}}
|
||||
|
||||
console.log(query)
|
||||
|
||||
for (const k of Object.keys(query))
|
||||
{
|
||||
if (query[k] == null) delete queries[k]
|
||||
else queries[k] = query[k]
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === null) {
|
||||
url.searchParams.delete(key);
|
||||
} else {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return router.push({query: queries})
|
||||
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Dispatch a custom event so other components (like Navigation) can react if they need to
|
||||
window.dispatchEvent(new Event('popstate'));
|
||||
}
|
||||
|
||||
export const router = {
|
||||
install: (app: any) => {
|
||||
app.config.globalProperties.$router = router;
|
||||
},
|
||||
push: (to: any) => {
|
||||
if (typeof to === 'string') {
|
||||
window.location.href = to;
|
||||
} else if (to.path) {
|
||||
let url = to.path;
|
||||
if (to.query) {
|
||||
const params = new URLSearchParams(to.query);
|
||||
url += '?' + params.toString();
|
||||
}
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import {Vue} from "vue-class-component";
|
||||
|
||||
/**
|
||||
* Same as python's range
|
||||
*
|
||||
@@ -41,9 +39,9 @@ export function minMax(val: number, min: number, max: number): number
|
||||
export type Keybinds = {[id: string]: (e: KeyboardEvent) => unknown}
|
||||
|
||||
/**
|
||||
* Key handler mixin
|
||||
* Key handler standalone helper class (not a Vue mixin)
|
||||
*/
|
||||
export class KeyHandler extends Vue
|
||||
export class KeyHandler
|
||||
{
|
||||
keybinds: Keybinds = {}
|
||||
_keybinds: Keybinds = {}
|
||||
|
||||
+54
-31
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="About" class="markdown-content" v-if="html">
|
||||
<Dynamic :template="html"></Dynamic>
|
||||
<div v-html="html" ref="markdownContainer"></div>
|
||||
|
||||
<Collapse title="<span class='emoji'>🎓</span> Research papers">
|
||||
<ZoteroPublication v-for="item in publications" :key="item.key" :item="item"/>
|
||||
@@ -9,49 +9,57 @@
|
||||
<Loading v-else></Loading>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref, nextTick} from 'vue'
|
||||
import {marked} from 'marked';
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import {parseExtensions} from '@/scripts/extended_markdown'
|
||||
import ZoteroPublication from "@/components/ZoteroPublication.vue";
|
||||
import {hosts} from "@/scripts/constants";
|
||||
import Loading from "@/components/Loading.vue";
|
||||
import Collapse from "@/components/Collapse.vue";
|
||||
import {hosts, $} from "@/scripts/constants";
|
||||
import {ZoteroAttachment, ZoteroItem} from "@/scripts/zotero";
|
||||
import aboutMd from '@/data/about/README.md?raw';
|
||||
|
||||
@Options({components: {Loading, ZoteroPublication}})
|
||||
export default class About extends Vue
|
||||
{
|
||||
html = ""
|
||||
publications: ZoteroItem[] = []
|
||||
const html = ref("")
|
||||
const markdownContainer = ref<HTMLElement | null>(null)
|
||||
const publications = ref<ZoteroItem[]>([])
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
// Fetch readme
|
||||
fetch(`${hosts.content}/README.md`).then(it => it.text())
|
||||
.then(it => this.html = marked(parseExtensions(it.replace(emojiRegex(), (emoji) => {
|
||||
return `<span class="emoji">${emoji}</span>`
|
||||
}))))
|
||||
onMounted(async (): Promise<void> => {
|
||||
html.value = await marked.parse(parseExtensions(aboutMd.replace(emojiRegex(), (emoji) => {
|
||||
return `<span class="emoji">${emoji}</span>`
|
||||
})))
|
||||
|
||||
// Fetch publications from zotero
|
||||
fetch(`${hosts.api}/zotero.json`)
|
||||
.then(it => it.json()).then(it =>
|
||||
{
|
||||
// Filter out publications and attachments
|
||||
this.publications = it
|
||||
let files: ZoteroAttachment[] = it
|
||||
files = files.filter(it => it.data.itemType === 'attachment')
|
||||
this.publications = this.publications.filter(it => it.data.itemType !== 'attachment')
|
||||
nextTick(() => {
|
||||
if (markdownContainer.value) {
|
||||
$(markdownContainer.value).find('.collapse-block').accordion({
|
||||
collapsible: true,
|
||||
header: '.collapse-header',
|
||||
heightStyle: 'content',
|
||||
active: false
|
||||
});
|
||||
|
||||
// Add attachments to
|
||||
this.publications.forEach(it => it.attachments = files.filter(a => a.data.parentItem == it.key))
|
||||
})
|
||||
}
|
||||
}
|
||||
// Fix image heights from attributes
|
||||
markdownContainer.value.querySelectorAll('img[height]').forEach(img => {
|
||||
const h = img.getAttribute('height');
|
||||
if (h) (img as HTMLElement).style.height = h.includes('px') ? h : h + 'px';
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
fetch(`${hosts.api}/zotero.json`)
|
||||
.then(it => it.json()).then((it: ZoteroItem[]) => {
|
||||
publications.value = it
|
||||
let files: ZoteroAttachment[] = it as unknown as ZoteroAttachment[]
|
||||
files = files.filter(file => file.data.itemType === 'attachment')
|
||||
publications.value = publications.value.filter(pub => pub.data.itemType !== 'attachment')
|
||||
publications.value.forEach(pub => pub.attachments = files.filter(a => a.data.parentItem == pub.key))
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass">
|
||||
@import "../css/colors"
|
||||
@use "../css/colors"
|
||||
|
||||
#About
|
||||
width: min(600px, 80vw)
|
||||
@@ -59,6 +67,21 @@ export default class About extends Vue
|
||||
padding-bottom: 100px
|
||||
padding-top: 20px
|
||||
|
||||
.collapse-header
|
||||
margin: 0
|
||||
padding-top: 0.5em
|
||||
padding-bottom: 0.5em
|
||||
user-select: none
|
||||
border-bottom: none !important
|
||||
display: block
|
||||
|
||||
.collapse-header:not(.ui-accordion-header-active)::after
|
||||
content: '...'
|
||||
margin-left: 0.25em
|
||||
|
||||
.collapse-content
|
||||
padding-bottom: 0.5em
|
||||
|
||||
.emoji
|
||||
font-weight: normal
|
||||
|
||||
|
||||
+50
-38
@@ -10,64 +10,76 @@
|
||||
<span v-if="category">📂{{category}}</span>
|
||||
<span class="no-after" v-if="post && activePost">{{activePost.title}}</span>
|
||||
</div>
|
||||
<BlogPostPreview v-for="m of filteredPosts" :key="m" :meta="m" :active="m === activePost"/>
|
||||
<BlogPostPreview v-for="m of filteredPosts" :key="m.id" :meta="m" :active="m === activePost"/>
|
||||
</div>
|
||||
<Loading v-else></Loading>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
<script setup lang="ts">
|
||||
import BlogPostPreview from "@/components/BlogPost.vue";
|
||||
import {hosts} from "@/scripts/constants";
|
||||
import {Prop} from "vue-property-decorator";
|
||||
import Loading from "@/components/Loading.vue";
|
||||
import {BlogMeta, BlogPost} from "@/scripts/models";
|
||||
import {BlogMeta} from "@/scripts/models";
|
||||
import {Ref, ref, computed, onMounted} from "vue";
|
||||
import metasJson from "@/data/blog/metas.json";
|
||||
import {globals} from "@/scripts/global";
|
||||
import {Router} from "vue-router";
|
||||
|
||||
export let staticMeta: BlogMeta = {tags: [], categories: [], posts: []}
|
||||
let $router: Router
|
||||
|
||||
@Options({components: {Loading, BlogPostPreview}})
|
||||
export default class Blog extends Vue
|
||||
{
|
||||
@Prop() post?: string
|
||||
@Prop() category?: string
|
||||
@Prop() tag?: string
|
||||
const p = defineProps<{
|
||||
post?: string,
|
||||
category?: string,
|
||||
tag?: string
|
||||
}>()
|
||||
|
||||
meta: BlogMeta = {tags: [], categories: [], posts: []}
|
||||
let meta: Ref<BlogMeta> = ref(metasJson as unknown as BlogMeta)
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
fetch(`${hosts.content}/content/generated/metas.json`).then(it => it.json()).then(it => {
|
||||
this.meta = it
|
||||
staticMeta = it
|
||||
})
|
||||
const query = ref({
|
||||
post: p.post,
|
||||
category: p.category,
|
||||
tag: p.tag
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
globals.staticMeta = meta.value
|
||||
|
||||
const initQuery = () => {
|
||||
const url = new URL(window.location.href);
|
||||
query.value = {
|
||||
post: url.searchParams.get('post') || undefined,
|
||||
category: url.searchParams.get('category') || undefined,
|
||||
tag: url.searchParams.get('tag') || undefined
|
||||
}
|
||||
}
|
||||
|
||||
initQuery()
|
||||
window.addEventListener('popstate', initQuery)
|
||||
})
|
||||
|
||||
get activePost(): BlogPost | null
|
||||
{
|
||||
const p = this.filteredPosts
|
||||
if (p.length == 0) return null
|
||||
return this.post ? p.filter(it => it.url_name == this.post)[0] : p[0].pinned ? p[0] : null
|
||||
}
|
||||
const filteredPosts = computed(() => {
|
||||
const posts = meta.value.posts.filter(it => it.pinned || (query.value.tag ? it.tags.includes(query.value.tag) :
|
||||
query.value.category ? it.category == query.value.category : true))
|
||||
|
||||
get filteredPosts(): BlogPost[]
|
||||
{
|
||||
const posts = this.meta.posts.filter(it => it.pinned || (this.tag ? it.tags.includes(this.tag) :
|
||||
this.category ? it.category == this.category : true))
|
||||
// Put pinned posts on top
|
||||
posts.sort((a, b) => (b.pinned ?? 0) - (a.pinned ?? 0))
|
||||
|
||||
// Put pinned posts on top
|
||||
posts.sort((a, b) => (b.pinned ?? 0) - (a.pinned ?? 0))
|
||||
return posts
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
}
|
||||
const activePost = computed(() => {
|
||||
const posts = filteredPosts.value
|
||||
if (posts.length == 0) return null
|
||||
if (!query.value.post) return null
|
||||
return posts.filter(it => it.url_name == query.value.post)[0]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import "src/css/colors"
|
||||
@import "src/css/responsive"
|
||||
@use "../css/colors"
|
||||
@use "../css/responsive"
|
||||
|
||||
#breadcrumb
|
||||
color: $color-text-light
|
||||
color: colors.$color-text-light
|
||||
margin-bottom: 20px
|
||||
|
||||
span:not(.no-after):after
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="Home" class="fbox-center f-grow1">
|
||||
<div id="box">
|
||||
<div class="font-script-en">Hykilpikonna's</div>
|
||||
<div class="font-script-en">Azalea's</div>
|
||||
<div class="font-script-en bold">Road Less Traveled</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ import 'tg-blog/dist/style.css'
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import "src/css/colors"
|
||||
@import "src/css/responsive"
|
||||
@use "../css/colors"
|
||||
@use "../css/responsive"
|
||||
|
||||
.title
|
||||
text-align: left
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div id="NewHome">
|
||||
<canvas id="three"></canvas>
|
||||
|
||||
<!-- Editor controls -->
|
||||
<div id="editor-controls">
|
||||
<div>Editor</div>
|
||||
|
||||
<div class="separator"/>
|
||||
|
||||
<div>New</div>
|
||||
<div>Save</div>
|
||||
|
||||
<div class="separator"/>
|
||||
|
||||
<div id="colors" class="fbox-h">
|
||||
<div class="color" v-for="(c, i) in colors" :key="i" :style="{'background-color': c ?? '#333'}"
|
||||
@click="e => openPicker(e, c)">
|
||||
<div>{{(i + 1) % 10}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f-grow1"></div>
|
||||
|
||||
<div class="separator"/>
|
||||
|
||||
<div @click="_ => toggle('sky')">Sky</div>
|
||||
</div>
|
||||
<MyColorPicker v-if="pickerColor" :color="pickerColor" style="z-index: 3"
|
||||
@close="pickerColor = ''" :initial-pos="initialPos"
|
||||
@updatePalette="p => colors = p[0]"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options} from 'vue-class-component';
|
||||
import {camera, editor, objects, start} from "@/animation/Home";
|
||||
import {config} from "@/animation/Config";
|
||||
import {KeyHandler, range} from "@/scripts/utils";
|
||||
import MyColorPicker from "@/components/color/ColorPicker.vue";
|
||||
|
||||
@Options({components: {MyColorPicker}})
|
||||
export default class NewHome extends KeyHandler
|
||||
{
|
||||
editMode = config.editMode
|
||||
|
||||
colors = localStorage.getItem('palette') ? JSON.parse(localStorage.getItem('palette') as string)[0] :
|
||||
range(10).map(_ => '#ffa8a8')
|
||||
|
||||
pickerColor = ''
|
||||
initialPos = {x: 0, y: 0}
|
||||
started = false
|
||||
|
||||
created(): void
|
||||
{
|
||||
// Escape to close picker
|
||||
this.keybinds.Escape = _ => this.pickerColor = ''
|
||||
|
||||
// Pick colors
|
||||
range(10).forEach(i => this.keybinds[((i + 1) % 10)+''] = _ => editor.color = this.colors[i])
|
||||
|
||||
// Camera position binds
|
||||
this.keybinds.ArrowLeft = _ => camera.position.x -= 1
|
||||
this.keybinds.ArrowRight = _ => camera.position.x += 1
|
||||
this.keybinds.ArrowUp = _ => camera.position.y += 1
|
||||
this.keybinds.ArrowDown = _ => camera.position.y -= 1
|
||||
this.keybinds.J = _ => camera.rotateY(Math.PI / 60)
|
||||
this.keybinds.L = _ => camera.rotateY(-Math.PI / 60)
|
||||
this.keybinds.I = _ => camera.rotateX(Math.PI / 60)
|
||||
this.keybinds.K = _ => camera.rotateX(-Math.PI / 60)
|
||||
this.keybinds.Ctrl0 = _ =>
|
||||
{
|
||||
camera.position.set(0, 0, 200)
|
||||
camera.lookAt(0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
mounted(): void
|
||||
{
|
||||
if (!this.started)
|
||||
{
|
||||
start('three')
|
||||
this.started = true
|
||||
}
|
||||
}
|
||||
|
||||
toggle(s: string): void
|
||||
{
|
||||
if (s == 'sky')
|
||||
{
|
||||
objects.hemiLight.visible = !objects.hemiLight.visible
|
||||
objects.dirLight.visible = !objects.dirLight.visible
|
||||
objects.sky.visible = !objects.sky.visible
|
||||
}
|
||||
}
|
||||
|
||||
openPicker(e: MouseEvent, c: string): void
|
||||
{
|
||||
this.pickerColor = c
|
||||
this.initialPos = {x: e.clientX - 150, y: e.clientY + 50}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
#three
|
||||
width: 100vw
|
||||
height: 100vh
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
z-index: 1
|
||||
cursor: none
|
||||
|
||||
#editor-controls
|
||||
position: absolute
|
||||
z-index: 2
|
||||
user-select: none
|
||||
|
||||
// Positioning
|
||||
top: 20px
|
||||
left: 50px
|
||||
height: 50px
|
||||
width: calc(100vw - 100px - 40px)
|
||||
padding: 0 20px
|
||||
border-radius: 50px
|
||||
|
||||
// Flex center
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
// Colors
|
||||
color: #ffeedb
|
||||
background-image: linear-gradient(180deg, #000000 0%, #434343 100%)
|
||||
|
||||
// Separator
|
||||
.separator
|
||||
width: 1px
|
||||
height: 50%
|
||||
border-radius: 100px
|
||||
background-color: rgba(255, 238, 219, 0.37)
|
||||
|
||||
#colors
|
||||
align-items: center
|
||||
|
||||
.color
|
||||
width: 12px
|
||||
height: 12px
|
||||
|
||||
div
|
||||
margin-top: -10px
|
||||
font-size: 8px
|
||||
color: #8f8f8f
|
||||
|
||||
+ .color
|
||||
margin-left: 5px
|
||||
|
||||
#editor-controls > * + *
|
||||
margin-left: 10px
|
||||
|
||||
</style>
|
||||
+5
-15
@@ -8,25 +8,15 @@
|
||||
<router-link class="rlink" to="/kitchen-menu">🍳 小桂桂的私房菜 菜单</router-link>
|
||||
<router-link class="rlink" to="/friends">🎎 朋友们</router-link>
|
||||
<a href="https://cast.hydev.org">📹 公开直播间</a>
|
||||
<a href="http://status.hydev.org/">🔌 看看服务器们过得怎么样</a>
|
||||
<a href="https://status.hydev.org/">🔌 看看服务器们过得怎么样</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
|
||||
@Options({})
|
||||
export default class Others extends Vue
|
||||
{
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import "src/css/global"
|
||||
@import "src/css/colors"
|
||||
@import "src/css/responsive"
|
||||
@use "../css/global"
|
||||
@use "../css/colors"
|
||||
@use "../css/responsive"
|
||||
|
||||
.links
|
||||
display: flex
|
||||
@@ -38,7 +28,7 @@ a
|
||||
@extend .card
|
||||
@extend .clickable
|
||||
|
||||
color: $color-text-main
|
||||
color: colors.$color-text-main
|
||||
text-decoration: none
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref} from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
interface PhotoMetadata {
|
||||
id: string
|
||||
owner_key: string
|
||||
upload_time: string
|
||||
original_photo: string
|
||||
edited_photo: string
|
||||
thumbnail: string
|
||||
thumbnail_edited: string
|
||||
exif: {[id: string]: string}
|
||||
}
|
||||
|
||||
function detRandom(seed: string): number {
|
||||
return Array.from(seed).reduce((acc, char) => (acc + char.charCodeAt(0) * 65535) % 22859, 0) / 22859
|
||||
}
|
||||
|
||||
async function waitTruthy<T>(condition: () => T, interval = 100): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
const value = condition()
|
||||
if (value) resolve(value)
|
||||
else setTimeout(check, interval)
|
||||
}
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const photos = ref<PhotoMetadata[]>([])
|
||||
const photoRows = ref<PhotoMetadata[][]>([])
|
||||
|
||||
const rowProbabilityTable: Record<number, number> = {
|
||||
1: 0,
|
||||
2: 0.3,
|
||||
3: 0.5
|
||||
}
|
||||
|
||||
const initPhotos = async () => {
|
||||
photos.value = await (await fetch('https://p.aza.moe/photos')).json()
|
||||
photos.value.sort((a, b) => (a.exif.DateTime < b.exif.DateTime ? 1 : -1))
|
||||
|
||||
const rows: PhotoMetadata[][] = []
|
||||
let currentRow: PhotoMetadata[] = []
|
||||
|
||||
photos.value.forEach((p) => {
|
||||
if (currentRow.length === 0) currentRow.push(p)
|
||||
else if (currentRow.length >= 3) {
|
||||
rows.push(currentRow)
|
||||
currentRow = [p]
|
||||
} else {
|
||||
const singleChance = detRandom(p.original_photo)
|
||||
if (singleChance < rowProbabilityTable[currentRow.length]) {
|
||||
rows.push(currentRow)
|
||||
currentRow = [p]
|
||||
} else currentRow.push(p)
|
||||
}
|
||||
})
|
||||
|
||||
if (currentRow.length > 0) rows.push(currentRow)
|
||||
photoRows.value = rows
|
||||
}
|
||||
|
||||
const url = (s: string): string => {
|
||||
s = s.replace('data/photos', 'static').replace('./', '')
|
||||
return `https://p.aza.moe/${s}`
|
||||
}
|
||||
|
||||
const randomRotation = (s: string): string => {
|
||||
const angle = (detRandom(s) * 20) - 10
|
||||
return `rotate(${angle}deg)`
|
||||
}
|
||||
|
||||
const clickPhoto = async (p: PhotoMetadata, e: MouseEvent) => {
|
||||
console.log("Clicked photo:", p.id)
|
||||
const dom = e.currentTarget as HTMLDivElement
|
||||
const photoEl = dom.querySelector('.photo-wrapper') as HTMLDivElement
|
||||
|
||||
photoEl.style.viewTransitionName = `photo-${p.id}`
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
dom.classList.toggle('active')
|
||||
document.getElementsByClassName('blur')[0].toggleAttribute('hidden')
|
||||
})
|
||||
|
||||
await transition.finished
|
||||
photoEl.style.viewTransitionName = ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initPhotos()
|
||||
|
||||
if (props.id) {
|
||||
const photoEl = await waitTruthy(() => document.getElementById(`photo-${props.id}`))
|
||||
photoEl.click()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="title">
|
||||
<div class="font-script-en bold">The Wandering Gallery</div>
|
||||
<div class="subtitle <sm:hidden">想要把旅行中用相机拍到好看照片时的喜悦分享给幸运的路人,所以买了便携照片打印机、搭了这个网页!</div>
|
||||
</div>
|
||||
<div class="outer-grid">
|
||||
<div v-for="row in photoRows" :key="row[0].id" flex justify-center :class="`grid-cols-${row.length}`">
|
||||
<div v-for="p in row" :key="p.id" @click.capture="async e => await clickPhoto(p, e)"
|
||||
class="img-container" cursor-pointer :id="`photo-${p.id}`">
|
||||
<img class="photo" w-full h-full object-contain opacity-0 :src="url(p.thumbnail_edited)" :alt="p.id"/>
|
||||
<div class="photo-abs-container" absolute inset-0 flex justify-center items-center>
|
||||
<div class="photo-wrapper" :style="{transform: randomRotation(p.id)}">
|
||||
<img class="photo" w-full object-contain :src="url(p.thumbnail_edited)" :alt="p.id" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div flex w-full justify-center absolute position-top-none>-->
|
||||
<!-- <img class="pin" src="/thumb%20tack%202%20plain.png" alt=""/>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="blur" hidden pos-fixed inset-0 backdrop-blur-sm z-5></div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
@use "../css/colors"
|
||||
@use "../css/responsive"
|
||||
|
||||
.blur
|
||||
z-index: 2500
|
||||
|
||||
.title
|
||||
margin-top: 8rem
|
||||
margin-bottom: 6rem
|
||||
|
||||
.bold
|
||||
font-size: 3em
|
||||
|
||||
.img-container
|
||||
margin: -0.5rem
|
||||
max-width: 50%
|
||||
position: relative
|
||||
|
||||
.img-container.active
|
||||
position: unset
|
||||
|
||||
img.pin
|
||||
display: none
|
||||
.photo-abs-container
|
||||
position: fixed
|
||||
z-index: 3000
|
||||
|
||||
.photo-wrapper
|
||||
transform: rotate(0deg) !important
|
||||
|
||||
img.photo
|
||||
z-index: 3001
|
||||
pointer-events: auto
|
||||
|
||||
img.photo
|
||||
clip-path: inset(2.8% 1.8% 2.4%)
|
||||
pointer-events: none
|
||||
|
||||
div.photo-abs-container
|
||||
position: absolute
|
||||
inset: 0
|
||||
z-index: 1000
|
||||
|
||||
div.photo-wrapper
|
||||
filter: drop-shadow(0px 1px 2px rgba(0, 0, 0, 0.3))
|
||||
|
||||
img.pin
|
||||
width: 40px
|
||||
height: 40px
|
||||
z-index: 2000
|
||||
</style>
|
||||
@@ -23,61 +23,54 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref} from 'vue'
|
||||
import {fab, hosts} from "@/scripts/constants";
|
||||
import {shuffle} from "@/scripts/utils";
|
||||
|
||||
export interface Friend {
|
||||
interface Friend {
|
||||
name: string
|
||||
avatar: string
|
||||
banner: string
|
||||
|
||||
desc?: string
|
||||
[id: string]: string | undefined
|
||||
}
|
||||
|
||||
const excludes = new Set(["name", "avatar", "banner", "desc"])
|
||||
const icons = {
|
||||
const icons: {[id: string]: string} = {
|
||||
blog: 'fas fa-book'
|
||||
}
|
||||
|
||||
@Options({components: {}})
|
||||
export default class Friends extends Vue
|
||||
{
|
||||
friends: Friend[] = []
|
||||
import friendsJson from "@/data/friends.json";
|
||||
|
||||
async created()
|
||||
{
|
||||
this.friends = await (await fetch(`${hosts.content}/content/generated/friends/friends.json`)).json()
|
||||
const friends = ref<Friend[]>(friendsJson as Friend[])
|
||||
|
||||
// Fix avatar relative url
|
||||
this.friends.forEach(f => {
|
||||
if (!f.avatar.startsWith('http')) f.avatar = `${hosts.content}/${f.avatar}`
|
||||
if (f.banner && !f.banner.startsWith('http')) f.banner = `${hosts.content}/${f.banner}`
|
||||
})
|
||||
this.friends = shuffle(this.friends)
|
||||
}
|
||||
|
||||
bgStyle(f: Friend)
|
||||
{
|
||||
if (f.banner) return {'background-image': `url("${f.banner}")`}
|
||||
else return {}
|
||||
}
|
||||
|
||||
getFriendLinks(f: Friend): { link: string, icon: string }[]
|
||||
{
|
||||
return Object.entries(f).filter(pair => !excludes.has(pair[0].toString()))
|
||||
.map(pair => {
|
||||
return { link: pair[1], icon: fab.includes(pair[0]) ? `fab fa-${pair[0]}` :
|
||||
pair[0] in icons ? icons[pair[0]] : pair[0] }
|
||||
})
|
||||
}
|
||||
const bgStyle = (f: Friend) => {
|
||||
if (f.banner) return {'background-image': `url("${f.banner}")`}
|
||||
return {}
|
||||
}
|
||||
|
||||
const getFriendLinks = (f: Friend): { link: string, icon: string }[] => {
|
||||
return Object.entries(f)
|
||||
.filter(([key, value]) => !excludes.has(key) && typeof value === 'string')
|
||||
.map(([key, value]) => ({
|
||||
link: value as string,
|
||||
icon: fab.includes(key) ? `fab fa-${key}` : (key in icons ? icons[key] : key)
|
||||
}))
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
friends.value.forEach(f => {
|
||||
if (!f.avatar.startsWith('http')) f.avatar = `${hosts.content}/${f.avatar}`
|
||||
if (f.banner && !f.banner.startsWith('http')) f.banner = `${hosts.content}/${f.banner}`
|
||||
})
|
||||
friends.value = shuffle(friends.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import "src/css/colors"
|
||||
@import "src/css/responsive"
|
||||
@use "../../css/colors"
|
||||
@use "../../css/responsive"
|
||||
|
||||
$card-min-width: 320px
|
||||
|
||||
@@ -126,7 +119,7 @@ $card-min-width: 320px
|
||||
flex: 1
|
||||
|
||||
a
|
||||
color: $color-text-main
|
||||
color: colors.$color-text-main
|
||||
|
||||
a + a
|
||||
margin-left: 10px
|
||||
|
||||
+27
-51
@@ -23,37 +23,26 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
<script setup lang="ts">
|
||||
import {ref} from 'vue'
|
||||
|
||||
export enum Flavor
|
||||
{
|
||||
light,
|
||||
normal,
|
||||
salty
|
||||
}
|
||||
|
||||
export interface MenuItem
|
||||
{
|
||||
interface MenuItem {
|
||||
name: string
|
||||
sub?: string
|
||||
img?: string
|
||||
recommend?: boolean
|
||||
original?: boolean
|
||||
|
||||
id?: number
|
||||
}
|
||||
|
||||
export interface MenuCategory
|
||||
{
|
||||
interface MenuCategory {
|
||||
cat: string
|
||||
subtitle?: string
|
||||
items: MenuItem[]
|
||||
|
||||
column?: number
|
||||
}
|
||||
|
||||
export const menu: MenuCategory[] = [
|
||||
const menu: MenuCategory[] = [
|
||||
{
|
||||
cat: '🍖 猪肉',
|
||||
items: [
|
||||
@@ -141,43 +130,30 @@ export const menu: MenuCategory[] = [
|
||||
},
|
||||
]
|
||||
|
||||
@Options({components: {}})
|
||||
export default class Menu extends Vue
|
||||
{
|
||||
max_cols = 2
|
||||
cols: MenuCategory[][] = new Array(this.max_cols)
|
||||
const maxCols = 2
|
||||
const cols = ref<MenuCategory[][]>(Array.from({length: maxCols}, () => []))
|
||||
|
||||
created()
|
||||
{
|
||||
// Calculate menu layout
|
||||
const tmp = Array.from(menu)
|
||||
tmp.sort((a, b) => b.items.length - a.items.length)
|
||||
const tmp = Array.from(menu)
|
||||
tmp.sort((a, b) => b.items.length - a.items.length)
|
||||
|
||||
// Two columns
|
||||
let col_counts = new Array(this.max_cols).fill(0)
|
||||
for (const cat of tmp)
|
||||
{
|
||||
// Get column index with minimal item count
|
||||
let col = col_counts.indexOf(Math.min(...col_counts))
|
||||
|
||||
cat.column = col
|
||||
col_counts[col] += cat.items.length
|
||||
}
|
||||
|
||||
// Separate arrays by column
|
||||
for (let i = 0; i < this.max_cols; i++)
|
||||
this.cols[i] = menu.filter(it => it.column == i)
|
||||
|
||||
// Assign ID to each item
|
||||
let id = 0
|
||||
this.cols.forEach(col => col.forEach(cat => cat.items.forEach(it => it.id = id++)))
|
||||
}
|
||||
const colCounts = new Array(maxCols).fill(0)
|
||||
for (const cat of tmp) {
|
||||
const col = colCounts.indexOf(Math.min(...colCounts))
|
||||
cat.column = col
|
||||
colCounts[col] += cat.items.length
|
||||
}
|
||||
|
||||
for (let i = 0; i < maxCols; i++) {
|
||||
cols.value[i] = menu.filter(it => it.column == i)
|
||||
}
|
||||
|
||||
let id = 0
|
||||
cols.value.forEach(col => col.forEach(cat => cat.items.forEach(it => it.id = id++)))
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
@import "src/css/colors"
|
||||
@import "src/css/responsive"
|
||||
@use "../../css/colors"
|
||||
@use "../../css/responsive"
|
||||
|
||||
.columns
|
||||
display: flex
|
||||
@@ -191,6 +167,8 @@ export default class Menu extends Vue
|
||||
white-space: nowrap
|
||||
|
||||
.category
|
||||
margin-bottom: 1em
|
||||
|
||||
.cat
|
||||
font-size: 1.2em
|
||||
font-weight: bold
|
||||
@@ -198,16 +176,14 @@ export default class Menu extends Vue
|
||||
.subtitle
|
||||
font-size: 0.8em
|
||||
margin-bottom: 0.5em
|
||||
color: $color-text-light
|
||||
|
||||
margin-bottom: 1em
|
||||
color: colors.$color-text-light
|
||||
|
||||
.items
|
||||
.sub
|
||||
font-size: 0.7em
|
||||
|
||||
.item.recommend
|
||||
color: $color-text-special
|
||||
color: colors.$color-text-special
|
||||
|
||||
.item.original:after
|
||||
content: '原创'
|
||||
|
||||
@@ -4,14 +4,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Options, Vue} from 'vue-class-component';
|
||||
|
||||
@Options({components: {}})
|
||||
export default class Projects extends Vue
|
||||
{
|
||||
|
||||
}
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
|
||||
+2
-3
@@ -5,16 +5,15 @@
|
||||
"strict": false,
|
||||
"jsx": "preserve",
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig, presetWind3, presetAttributify, presetIcons, presetTypography, presetWebFonts, transformerDirectives } from "unocss";
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetWind3(),
|
||||
presetAttributify(),
|
||||
presetIcons(),
|
||||
presetTypography(),
|
||||
]
|
||||
})
|
||||
+7
-1
@@ -1,12 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import UnoCSS from 'unocss/vite';
|
||||
import path from "path";
|
||||
|
||||
const src = path.resolve(__dirname, 'src')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue()
|
||||
vue(), UnoCSS()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -16,5 +17,10 @@ export default defineConfig({
|
||||
vue: "vue/dist/vue.esm-bundler.js"
|
||||
},
|
||||
dedupe: ['vue'],
|
||||
},
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user