mirror of
https://github.com/fmhy/edit.git
synced 2025-08-01 00:32:30 +10:00
m9ve website code outsidr
This commit is contained in:
parent
15a56af368
commit
df4eecc405
87 changed files with 3108 additions and 59 deletions
|
@ -1,14 +0,0 @@
|
|||
Copyright (c) taskylizard. Apache License 2.0
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
This is the website source code to be used with [VitePress](https://vitepress.dev/).
|
||||
|
||||
Licensed under the Apache License v2.0, see [LICENSE](./LICENSE) for more information.
|
|
@ -6,6 +6,11 @@ import OptimizeExclude from 'vite-plugin-optimize-exclude'
|
|||
import Terminal from 'vite-plugin-terminal'
|
||||
import vueDevtools from 'vite-plugin-vue-devtools'
|
||||
import { defineConfig } from 'vitepress'
|
||||
import { generateFeed, generateImages, generateMeta } from '../../website/hooks'
|
||||
import { defs, emojiRender, movePlugin } from '../../website/markdown/emoji'
|
||||
import { headersPlugin } from '../../website/markdown/headers'
|
||||
import { toggleStarredPlugin } from '../../website/markdown/toggle-starred'
|
||||
import { transformsPlugin } from '../../website/transformer'
|
||||
import {
|
||||
commitRef,
|
||||
feedback,
|
||||
|
@ -16,11 +21,6 @@ import {
|
|||
sidebar,
|
||||
socialLinks
|
||||
} from './constants'
|
||||
import { generateFeed, generateImages, generateMeta } from './hooks'
|
||||
import { defs, emojiRender, movePlugin } from './markdown/emoji'
|
||||
import { headersPlugin } from './markdown/headers'
|
||||
import { toggleStarredPlugin } from './markdown/toggleStarred'
|
||||
import { transformsPlugin } from './transformer'
|
||||
|
||||
// @unocss-include
|
||||
|
||||
|
@ -82,7 +82,7 @@ export default defineConfig({
|
|||
transformHead: async (context) => generateMeta(context, meta.hostname),
|
||||
buildEnd: async (context) => {
|
||||
generateImages(context)
|
||||
.then(() => generateFeed(context))
|
||||
.then(() => generateFeed(meta, context))
|
||||
.finally(() => consola.success('Success!'))
|
||||
},
|
||||
vite: {
|
||||
|
@ -97,13 +97,16 @@ export default defineConfig({
|
|||
{
|
||||
find: /^.*VPSwitchAppearance\.vue$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL('./theme/Appearance.vue', import.meta.url)
|
||||
new URL('../../website/theme/Appearance.vue', import.meta.url)
|
||||
)
|
||||
},
|
||||
{
|
||||
find: /^.*VPButton\.vue$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL('./theme/components/VPButton.vue', import.meta.url)
|
||||
new URL(
|
||||
'../../website/theme/components/VPButton.vue',
|
||||
import.meta.url
|
||||
)
|
||||
)
|
||||
}
|
||||
]
|
||||
|
@ -128,7 +131,7 @@ export default defineConfig({
|
|||
filepath: './.cache/imports.json'
|
||||
}
|
||||
}),
|
||||
transformsPlugin(),
|
||||
transformsPlugin(meta),
|
||||
{
|
||||
name: 'custom:adjust-order',
|
||||
configResolved(c) {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
import type { DefaultTheme } from 'vitepress'
|
||||
import consola from 'consola'
|
||||
import { transform, transformGuide } from './transformer'
|
||||
import { transform, transformGuide } from '../../website/transformer'
|
||||
|
||||
// @unocss-include
|
||||
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,43 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
tw="w-full h-full bg-black flex flex-col"
|
||||
style="
|
||||
background-image: linear-gradient(
|
||||
43deg,
|
||||
#b794f4 2%,
|
||||
#b18df2 7.5%,
|
||||
#ab87ef 13%,
|
||||
#9f7aea 24%,
|
||||
#8c6ee2 32%,
|
||||
#7864d8 40%,
|
||||
#4c51bf 56%,
|
||||
#4949ae 60.5%,
|
||||
#46419b 65%,
|
||||
#3c366b 74%,
|
||||
#2f315d 80.5%,
|
||||
#272d47 87%,
|
||||
#1a202c 100%
|
||||
);
|
||||
"
|
||||
>
|
||||
<span
|
||||
tw="p-10 w-full min-h-0 grow flex flex-col items-center justify-between"
|
||||
>
|
||||
<span tw="w-full flex justify-between items-center text-5xl font-medium">
|
||||
<span tw="flex items-center">
|
||||
<span tw="text-zinc-100 ml-2 mt-1 font-semibold">
|
||||
freemediaheckyeah
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span tw="w-full pr-56 flex flex-col items-start justify-end">
|
||||
<span style="color: #f3f4f6" tw="text-6xl font-bold" v-html="title" />
|
||||
<span style="color: #c0caf5" tw="mt-2 text-4xl" v-html="description" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
/**
|
||||
* Barrel generated using @taskylizard/tasker.
|
||||
*/
|
||||
|
||||
export * from './meta'
|
||||
export * from './opengraph'
|
||||
export * from './rss'
|
||||
export * from './satoriConfig'
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { HeadConfig, TransformContext } from 'vitepress'
|
||||
|
||||
export function generateMeta(context: TransformContext, hostname: string) {
|
||||
const head: HeadConfig[] = []
|
||||
const { pageData } = context
|
||||
|
||||
if (pageData.isNotFound) return head
|
||||
if (Object.keys(pageData.frontmatter).length === 0) return head
|
||||
|
||||
const url = `${hostname}/${pageData.relativePath.replace(/((^|\/)index)?\.md$/, '$2')}`
|
||||
|
||||
head.push(
|
||||
['link', { rel: 'canonical', href: url }],
|
||||
['meta', { property: 'og:url', content: url }],
|
||||
['meta', { name: 'twitter:url', content: url }],
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
['meta', { property: 'og:title', content: pageData.frontmatter.title }],
|
||||
['meta', { name: 'twitter:title', content: pageData.frontmatter.title }]
|
||||
)
|
||||
|
||||
if (pageData.frontmatter.description) {
|
||||
head.push(
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'og:description',
|
||||
content: pageData.frontmatter.description
|
||||
}
|
||||
],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
name: 'twitter:description',
|
||||
content: pageData.frontmatter.description
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if (pageData.frontmatter.image) {
|
||||
head.push([
|
||||
'meta',
|
||||
{
|
||||
property: 'og:image',
|
||||
content: `${hostname}/${pageData.frontmatter.image.replace(/^\//, '')}`
|
||||
}
|
||||
])
|
||||
head.push([
|
||||
'meta',
|
||||
{
|
||||
name: 'twitter:image',
|
||||
content: `${hostname}/${pageData.frontmatter.image.replace(/^\//, '')}`
|
||||
}
|
||||
])
|
||||
} else {
|
||||
const url = pageData.filePath.replace('index.md', '').replace('.md', '')
|
||||
const imageUrl = `${url}/__og_image__/og.png`
|
||||
.replaceAll('//', '/')
|
||||
.replace(/^\//, '')
|
||||
|
||||
head.push(
|
||||
['meta', { property: 'og:image', content: `${hostname}/${imageUrl}` }],
|
||||
['meta', { property: 'og:image:width', content: '1200' }],
|
||||
['meta', { property: 'og:image:height', content: '628' }],
|
||||
['meta', { property: 'og:image:type', content: 'image/png' }],
|
||||
[
|
||||
'meta',
|
||||
{ property: 'og:image:alt', content: pageData.frontmatter.title }
|
||||
],
|
||||
['meta', { name: 'twitter:image', content: `${hostname}/${imageUrl}` }],
|
||||
['meta', { name: 'twitter:image:width', content: '1200' }],
|
||||
['meta', { name: 'twitter:image:height', content: '628' }],
|
||||
[
|
||||
'meta',
|
||||
{ name: 'twitter:image:alt', content: pageData.frontmatter.title }
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if (pageData.frontmatter.tag) {
|
||||
head.push([
|
||||
'meta',
|
||||
{ property: 'article:tag', content: pageData.frontmatter.tag }
|
||||
])
|
||||
}
|
||||
|
||||
if (pageData.frontmatter.date) {
|
||||
head.push([
|
||||
'meta',
|
||||
{
|
||||
property: 'article:published_time',
|
||||
content: pageData.frontmatter.date
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
if (pageData.lastUpdated && pageData.frontmatter.lastUpdated !== false) {
|
||||
head.push([
|
||||
'meta',
|
||||
{
|
||||
property: 'article:modified_time',
|
||||
content: new Date(pageData.lastUpdated).toISOString()
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return head
|
||||
}
|
|
@ -1,141 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ContentData, SiteConfig } from 'vitepress'
|
||||
import type { SatoriOptions } from 'x-satori/vue'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { renderAsync } from '@resvg/resvg-js'
|
||||
import consola from 'consola'
|
||||
import { createContentLoader } from 'vitepress'
|
||||
import { satoriVue } from 'x-satori/vue'
|
||||
import { headers } from '../transformer/constants'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const __fonts = resolve(__dirname, '../fonts')
|
||||
|
||||
export async function generateImages(config: SiteConfig) {
|
||||
const pages = await createContentLoader('**/*.md', { excerpt: true }).load()
|
||||
const template = await readFile(resolve(__dirname, './Template.vue'), 'utf-8')
|
||||
|
||||
const fonts: SatoriOptions['fonts'] = [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-Regular.otf')),
|
||||
weight: 400,
|
||||
style: 'normal'
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-Medium.otf')),
|
||||
weight: 500,
|
||||
style: 'normal'
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-SemiBold.otf')),
|
||||
weight: 600,
|
||||
style: 'normal'
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-Bold.otf')),
|
||||
weight: 700,
|
||||
style: 'normal'
|
||||
}
|
||||
]
|
||||
|
||||
for (const page of pages) {
|
||||
await generateImage({
|
||||
page,
|
||||
template,
|
||||
outDir: config.outDir,
|
||||
fonts
|
||||
})
|
||||
}
|
||||
return consola.info('Generated opengraph images.')
|
||||
}
|
||||
|
||||
interface GenerateImagesOptions {
|
||||
page: ContentData
|
||||
template: string
|
||||
outDir: string
|
||||
fonts: SatoriOptions['fonts']
|
||||
}
|
||||
|
||||
async function generateImage({
|
||||
page,
|
||||
template,
|
||||
outDir,
|
||||
fonts
|
||||
}: GenerateImagesOptions) {
|
||||
const { frontmatter, url } = page
|
||||
|
||||
const _page = getPage(url)
|
||||
const title =
|
||||
frontmatter.layout === 'home'
|
||||
? (frontmatter.hero.name ?? frontmatter.title)
|
||||
: frontmatter.title
|
||||
? frontmatter.title
|
||||
: _page?.title
|
||||
|
||||
const description =
|
||||
frontmatter.layout === 'home'
|
||||
? (frontmatter.hero.tagline ?? frontmatter.description)
|
||||
: frontmatter.description
|
||||
? frontmatter.description
|
||||
: _page?.description
|
||||
|
||||
// consola.info(url, title, description)
|
||||
const options: SatoriOptions = {
|
||||
width: 1200,
|
||||
height: 628,
|
||||
fonts,
|
||||
props: {
|
||||
title,
|
||||
description
|
||||
}
|
||||
}
|
||||
|
||||
const svg = await satoriVue(options, template)
|
||||
|
||||
const render = await renderAsync(svg)
|
||||
|
||||
const outputFolder = resolve(outDir, url.slice(1), '__og_image__')
|
||||
const outputFile = resolve(outputFolder, 'og.png')
|
||||
|
||||
await mkdir(outputFolder, { recursive: true })
|
||||
|
||||
await writeFile(outputFile, render.asPng())
|
||||
}
|
||||
|
||||
function getPage(page: string) {
|
||||
// Get the page name
|
||||
const pageName = `${page}.md`.slice(1).split('/').at(-1)
|
||||
|
||||
// Find the header
|
||||
// TODO: This is a hacky way to find the header
|
||||
const header = Object.entries(headers).find(([key]) => key === pageName)
|
||||
if (!header) return
|
||||
|
||||
const { title, description } = header[1]
|
||||
|
||||
return {
|
||||
title,
|
||||
description
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ContentData, SiteConfig } from 'vitepress'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import consola from 'consola'
|
||||
import { Feed } from 'feed'
|
||||
import { createContentLoader } from 'vitepress'
|
||||
import { meta } from '../constants'
|
||||
|
||||
export async function generateFeed(config: SiteConfig): Promise<void> {
|
||||
const feed: Feed = new Feed({
|
||||
id: meta.hostname,
|
||||
link: meta.hostname,
|
||||
title: 'FMHY blog',
|
||||
description: meta.description,
|
||||
language: 'en-US',
|
||||
image: 'https://github.com/fmhy.png',
|
||||
favicon: `${meta.hostname}/favicon.ico`,
|
||||
copyright: 'Copyright (c) 2023-present FMHY'
|
||||
})
|
||||
|
||||
const posts: ContentData[] = await createContentLoader('posts/*.md', {
|
||||
excerpt: true,
|
||||
render: true,
|
||||
transform: (rawData) => {
|
||||
return rawData.sort((a, b) => {
|
||||
return (
|
||||
Number(new Date(b.frontmatter.date)) -
|
||||
Number(new Date(a.frontmatter.date))
|
||||
)
|
||||
})
|
||||
}
|
||||
}).load()
|
||||
|
||||
for (const { url, frontmatter, html } of posts) {
|
||||
feed.addItem({
|
||||
title: frontmatter.title as string,
|
||||
id: `${meta.hostname}${url.replace(/\/\d+\./, '/')}`,
|
||||
link: `${meta.hostname}${url.replace(/\/\d+\./, '/')}`,
|
||||
date: frontmatter.date,
|
||||
content: html?.replaceAll('​', '')
|
||||
})
|
||||
}
|
||||
|
||||
writeFileSync(path.join(config.outDir, 'feed.rss'), feed.rss2())
|
||||
return consola.info('Generated rss feed.')
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { SatoriOptions } from 'x-satori/vue'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineSatoriConfig } from 'x-satori/vue'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const __fonts = resolve(__dirname, '../fonts')
|
||||
|
||||
const fonts: SatoriOptions['fonts'] = [
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-Regular.otf')),
|
||||
weight: 400,
|
||||
style: 'normal'
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-Medium.otf')),
|
||||
weight: 500,
|
||||
style: 'normal'
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-SemiBold.otf')),
|
||||
weight: 600,
|
||||
style: 'normal'
|
||||
},
|
||||
{
|
||||
name: 'Inter',
|
||||
data: await readFile(resolve(__fonts, 'Inter-Bold.otf')),
|
||||
weight: 700,
|
||||
style: 'normal'
|
||||
}
|
||||
]
|
||||
|
||||
export default defineSatoriConfig({
|
||||
width: 1200,
|
||||
height: 628,
|
||||
fonts,
|
||||
props: {
|
||||
title: 'Title',
|
||||
description:
|
||||
'Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.',
|
||||
dir: '/j'
|
||||
}
|
||||
})
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MarkdownRenderer } from 'vitepress'
|
||||
|
||||
// FIXME: tasky: possibly write less horror jank?
|
||||
export function base64DecodePlugin(md: MarkdownRenderer) {
|
||||
const decode = (str: string): string =>
|
||||
Buffer.from(str, 'base64').toString('binary')
|
||||
// Save the original rule for backticks
|
||||
const defaultRender =
|
||||
md.renderer.rules.code_inline ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.code_inline = function (tokens, idx, options, env, self) {
|
||||
if (
|
||||
!env.frontmatter.title ||
|
||||
(env.frontmatter.title && env.frontmatter.title !== 'base64')
|
||||
) {
|
||||
return defaultRender(tokens, idx, options, env, self)
|
||||
}
|
||||
const token = tokens[idx]
|
||||
const content = token.content
|
||||
|
||||
return `<button class='base64' onclick="(function(btn){ const codeEl = btn.querySelector('code'); navigator.clipboard.writeText('${decode(
|
||||
content
|
||||
)}').then(() => { const originalText = codeEl.textContent; codeEl.textContent = 'Copied'; setTimeout(() => codeEl.textContent = originalText, 3000); }).catch(console.error); })(this)"><code>${content}</code></button>`
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MarkdownRenderer } from 'vitepress'
|
||||
import { icons as twemoji } from '@iconify-json/twemoji'
|
||||
|
||||
export const defs = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(twemoji.icons).map(([key]) => {
|
||||
return [key, '']
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function emojiRender(md: MarkdownRenderer) {
|
||||
md.renderer.rules.emoji = (tokens, idx) => {
|
||||
if (tokens[idx].markup.startsWith('star')) {
|
||||
return `<span class="i-twemoji-${tokens[idx].markup} starred"></span>`
|
||||
}
|
||||
return `<span class="i-twemoji-${tokens[idx].markup}"></span>`
|
||||
}
|
||||
}
|
||||
|
||||
export function movePlugin(
|
||||
plugins: { name: string }[],
|
||||
pluginAName: string,
|
||||
order: 'before' | 'after',
|
||||
pluginBName: string
|
||||
) {
|
||||
const pluginBIndex = plugins.findIndex((p) => p.name === pluginBName)
|
||||
if (pluginBIndex === -1) return
|
||||
|
||||
const pluginAIndex = plugins.findIndex((p) => p.name === pluginAName)
|
||||
if (pluginAIndex === -1) return
|
||||
|
||||
if (order === 'before' && pluginAIndex > pluginBIndex) {
|
||||
const pluginA = plugins.splice(pluginAIndex, 1)[0]
|
||||
plugins.splice(pluginBIndex, 0, pluginA)
|
||||
}
|
||||
|
||||
if (order === 'after' && pluginAIndex < pluginBIndex) {
|
||||
const pluginA = plugins.splice(pluginAIndex, 1)[0]
|
||||
plugins.splice(pluginBIndex, 0, pluginA)
|
||||
}
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
/**
|
||||
* Copyright (c) 2024 taskylizard
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MarkdownRenderer } from 'vitepress'
|
||||
import { headers } from '../transformer/constants'
|
||||
|
||||
const titles = Object.keys(headers).map((key) => headers[key].title)
|
||||
|
||||
export const headersPlugin = (md: MarkdownRenderer) => {
|
||||
// Add the Feedback component in the heading, before the link.
|
||||
//
|
||||
// Adding it after the link is closed prevents vitepress from properly
|
||||
// indexing the file's content.
|
||||
|
||||
md.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
|
||||
const result = self.renderToken(tokens, idx, options)
|
||||
|
||||
const idxClose =
|
||||
idx +
|
||||
tokens.slice(idx).findIndex((token) => token.type === 'heading_close')
|
||||
if (idxClose <= idx) return result
|
||||
|
||||
const level = tokens[idx].tag.slice(1)
|
||||
if (!titles.includes(env.frontmatter.title) || level !== '2') return result
|
||||
|
||||
// Find the token for the link.
|
||||
//
|
||||
// The token after `heading_open` contains the link as a child token.
|
||||
const children = tokens[idx + 1].children || []
|
||||
const linkOpenToken = children.findLast((c) => c.type === 'link_open')
|
||||
if (!linkOpenToken) return result
|
||||
|
||||
const heading = tokens[idxClose - 1]
|
||||
|
||||
linkOpenToken.meta = linkOpenToken.meta || {}
|
||||
linkOpenToken.meta.feedback = {
|
||||
heading: heading.content
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const defaultRender = md.renderer.rules.link_open
|
||||
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
const result = defaultRender!!!!!!!!!!(tokens, idx, options, env, self)
|
||||
|
||||
const meta = tokens[idx].meta
|
||||
if (!meta || !meta.feedback) return result
|
||||
|
||||
const heading = meta.feedback.heading || ''
|
||||
if (!heading) return result
|
||||
|
||||
return `<Feedback heading="${heading}" />${result}`
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MarkdownRenderer } from 'vitepress'
|
||||
|
||||
const excluded = ['Beginners Guide']
|
||||
|
||||
export function toggleStarredPlugin(md: MarkdownRenderer) {
|
||||
md.renderer.rules.list_item_open = (tokens, index, options, env, self) => {
|
||||
const contentToken = tokens[index + 2]
|
||||
|
||||
// Ensure the token exists
|
||||
if (contentToken) {
|
||||
const content = contentToken.content
|
||||
|
||||
if (
|
||||
!excluded.includes(env.frontmatter.title) &&
|
||||
(content.includes(':star:') || content.includes(':glowing-star:'))
|
||||
) {
|
||||
return `<li class="starred">`
|
||||
}
|
||||
}
|
||||
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import VPIconMoon from 'vitepress/dist/client/theme-default/components/icons/VPIconMoon.vue'
|
||||
import VPIconSun from 'vitepress/dist/client/theme-default/components/icons/VPIconSun.vue'
|
||||
|
||||
const { isDark } = useData()
|
||||
|
||||
const toggleAppearance = inject('toggle-appearance', () => {
|
||||
isDark.value = !isDark.value
|
||||
})
|
||||
|
||||
const supportsViewTransition = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
supportsViewTransition.value =
|
||||
'startViewTransition' in document &&
|
||||
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
title="VPSwitchAppearance"
|
||||
class="VPSwitchAppearance"
|
||||
:aria-checked="isDark"
|
||||
:data-view-transition="supportsViewTransition"
|
||||
@click="toggleAppearance"
|
||||
>
|
||||
<ClientOnly>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="!isDark" class="sun text-xl i-ph-sun-duotone" />
|
||||
<div v-else class="moon text-xl i-ph-moon-duotone" />
|
||||
</Transition>
|
||||
</ClientOnly>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.VPSwitchAppearance {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: color 0.5s;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
transition: color 0.25s;
|
||||
}
|
||||
|
||||
& > :deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
&[data-view-transition='false'] {
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,108 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { BProgress } from '@bprogress/core'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import Announcement from './components/Announcement.vue'
|
||||
import Sidebar from './components/SidebarCard.vue'
|
||||
|
||||
import '@bprogress/core/css'
|
||||
|
||||
const { isDark } = useData()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
BProgress.configure({
|
||||
showSpinner: false,
|
||||
easing: 'ease'
|
||||
})
|
||||
|
||||
router.onBeforeRouteChange = () => {
|
||||
BProgress.start()
|
||||
}
|
||||
|
||||
router.onAfterRouteChange = () => {
|
||||
BProgress.done()
|
||||
}
|
||||
|
||||
const enableTransitions = () =>
|
||||
'startViewTransition' in document &&
|
||||
window.matchMedia('(prefers-reduced-motion: no-preference)').matches
|
||||
|
||||
provide('toggle-appearance', async ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||
if (!enableTransitions()) {
|
||||
isDark.value = !isDark.value
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${Math.hypot(
|
||||
Math.max(x, innerWidth - x),
|
||||
Math.max(y, innerHeight - y)
|
||||
)}px at ${x}px ${y}px)`
|
||||
]
|
||||
|
||||
await document.startViewTransition(async () => {
|
||||
isDark.value = !isDark.value
|
||||
await nextTick()
|
||||
}).ready
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: isDark.value ? clipPath.reverse() : clipPath },
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'ease-in',
|
||||
pseudoElement: `::view-transition-${isDark.value ? 'old' : 'new'}(root)`
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const { Layout } = DefaultTheme
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #sidebar-nav-after>
|
||||
<Sidebar />
|
||||
</template>
|
||||
<template #home-hero-info-before>
|
||||
<Announcement />
|
||||
</template>
|
||||
<template #home-features-before>
|
||||
<p class="text-center text-lg text-text-2 mb-2">
|
||||
Or browse these pages
|
||||
<span class="inline-block i-twemoji:sparkles" />
|
||||
</p>
|
||||
</template>
|
||||
<Content />
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bprogress-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
.dark::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::view-transition-new(root),
|
||||
.dark::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.VPSwitchAppearance {
|
||||
width: 22px !important;
|
||||
}
|
||||
|
||||
.VPSwitchAppearance .check {
|
||||
transform: none !important;
|
||||
}
|
||||
</style>
|
|
@ -1,24 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Authors from './components/Authors.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
authors: string
|
||||
}>()
|
||||
|
||||
const formatDate = (raw: string): string => {
|
||||
const date = new Date(raw)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const { frontmatter } = useData()
|
||||
const authors = computed(() => props.authors.split(','))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ frontmatter.title }}</h1>
|
||||
<div>{{ frontmatter.description }} • {{ formatDate(frontmatter.date) }}</div>
|
||||
<Authors :authors="authors" />
|
||||
</template>
|
|
@ -1,67 +0,0 @@
|
|||
<!-- eslint-disable vue/require-v-for-key -->
|
||||
<script setup lang="ts">
|
||||
import { data as posts } from './posts.data'
|
||||
|
||||
const formatDate = (raw: string): string => {
|
||||
const date = new Date(raw)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section>
|
||||
<h1 class="flex items-center gap-2">Posts</h1>
|
||||
<p>Everything from Monthly Updates to fmhy updates.</p>
|
||||
|
||||
We also have a
|
||||
<a href="/feed.rss" target="_blank" title="RSS feed">
|
||||
<div class="i-carbon-rss vertical-top" />
|
||||
RSS feed.
|
||||
</a>
|
||||
</section>
|
||||
<template v-for="year in Object.keys(posts).reverse()" :key="year">
|
||||
<h2>{{ year }}</h2>
|
||||
<ul>
|
||||
<li v-for="post of posts[year]" :key="post.url">
|
||||
<article>
|
||||
<a :href="post.url" class="border-none">{{ post.title }}</a>
|
||||
-
|
||||
<dl class="m-0 inline">
|
||||
<dt class="sr-only">Published on</dt>
|
||||
<dd class="m-0 inline">
|
||||
<time :datetime="post.date" class="font-bold">
|
||||
{{ formatDate(post.date) }}
|
||||
</time>
|
||||
</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VPBadge {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
margin-left: 2px;
|
||||
padding: 0 10px;
|
||||
line-height: 22px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transform: translateY(-2px);
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding-right: 10px;
|
||||
vertical-align: middle;
|
||||
color: var(--vp-badge-tip-text);
|
||||
background-color: transparent;
|
||||
border-color: var(--vp-custom-block-tip-outline);
|
||||
}
|
||||
</style>
|
|
@ -1,13 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const { frontmatter } = useData()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="frontmatter.hero.announcement"
|
||||
:href="frontmatter.hero.announcement.link"
|
||||
class="mb-3 inline-flex items-center rounded-lg bg-[var(--vp-c-default-soft)] px-4 py-1 text-sm font-semibold"
|
||||
>
|
||||
{{ frontmatter.hero.announcement.title }}
|
||||
</a>
|
||||
</template>
|
|
@ -1,49 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
authors: string[]
|
||||
}>()
|
||||
|
||||
interface Author {
|
||||
name: string
|
||||
github: string
|
||||
}
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: 'nbats',
|
||||
github: 'https://github.com/nbats'
|
||||
},
|
||||
{
|
||||
name: 'Kai',
|
||||
github: 'https://github.com/Kai-FMHY'
|
||||
},
|
||||
{
|
||||
name: 'taskylizard',
|
||||
github: 'https://github.com/taskylizard'
|
||||
},
|
||||
{
|
||||
name: 'zinklog',
|
||||
github: 'https://github.com/zinklog2'
|
||||
},
|
||||
{
|
||||
name: 'Q',
|
||||
github: 'https://github.com/qiracy'
|
||||
}
|
||||
] satisfies Author[]
|
||||
|
||||
const authors = computed(() =>
|
||||
data.filter((author) => props.authors.includes(author.name))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-4 pt-2">
|
||||
<div v-for="(c, index) of authors" class="flex items-center gap-2">
|
||||
<img :src="`${c.github}.png`" class="h-8 w-8 rounded-full" />
|
||||
<a :href="c.github">{{ c.name }}</a>
|
||||
<span v-if="index < authors.length - 1">•</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,16 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
icon: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="g-[12px] mb-[8px] flex items-center">
|
||||
<span class="flex items-center">
|
||||
<div class="text-2xl" :class="icon" />
|
||||
<div class="ml-2 text-sm text-[var(--vp-c-text-2)]">
|
||||
<slot />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
|
@ -1,114 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { colors } from '@fmhy/colors'
|
||||
import { useStorage, useStyleTag } from '@vueuse/core'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport
|
||||
} from 'reka-ui'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const colorScales = [
|
||||
'50',
|
||||
'100',
|
||||
'200',
|
||||
'300',
|
||||
'400',
|
||||
'500',
|
||||
'600',
|
||||
'700',
|
||||
'800',
|
||||
'900',
|
||||
'950'
|
||||
] as const
|
||||
|
||||
type ColorNames = keyof typeof colors
|
||||
const selectedColor = useStorage<ColorNames>('preferred-color', 'swarm')
|
||||
|
||||
const colorOptions = Object.keys(colors).filter(
|
||||
(key) => typeof colors[key as keyof typeof colors] === 'object'
|
||||
)
|
||||
|
||||
const { css } = useStyleTag('', { id: 'brand-color' })
|
||||
|
||||
const updateThemeColor = (colorName: ColorNames) => {
|
||||
const colorSet = colors[colorName]
|
||||
|
||||
const cssVars = colorScales
|
||||
.map((scale) => `--vp-c-brand-${scale}: ${colorSet[scale]};`)
|
||||
.join('\n ')
|
||||
|
||||
css.value = `
|
||||
:root {
|
||||
${cssVars}
|
||||
--vp-c-brand-1: ${colorSet[500]};
|
||||
--vp-c-brand-2: ${colorSet[600]};
|
||||
--vp-c-brand-3: ${colorSet[800]};
|
||||
--vp-c-brand-soft: ${colorSet[400]};
|
||||
}
|
||||
|
||||
.dark {
|
||||
${cssVars}
|
||||
--vp-c-brand-1: ${colorSet[400]};
|
||||
--vp-c-brand-2: ${colorSet[500]};
|
||||
--vp-c-brand-3: ${colorSet[700]};
|
||||
--vp-c-brand-soft: ${colorSet[300]};
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
// Initialize theme color
|
||||
updateThemeColor(selectedColor.value)
|
||||
|
||||
watch(selectedColor, updateThemeColor)
|
||||
|
||||
const normalizeColorName = (colorName: string) =>
|
||||
colorName.replaceAll(/-/g, ' ').charAt(0).toUpperCase() +
|
||||
colorName.slice(1).replaceAll(/-/g, ' ')
|
||||
|
||||
const handleColorChange = (value: string) => {
|
||||
selectedColor.value = value as ColorNames
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="color-picker">
|
||||
<SelectRoot :model-value="selectedColor" @update:model-value="handleColorChange">
|
||||
<SelectTrigger
|
||||
class="inline-flex items-center justify-between px-3 py-2 text-sm bg-bg-alt border border-div rounded-md hover:bg-bg-elv transition-colors min-w-[180px] mx-auto align-left"
|
||||
aria-label="Select theme color">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block w-4 h-4 rounded-full border border-div"
|
||||
:style="{ backgroundColor: colors[selectedColor][500] }" />
|
||||
<SelectValue :placeholder="normalizeColorName(selectedColor)" />
|
||||
</div>
|
||||
<span class="i-lucide:chevron-down w-4 h-4 text-text-2" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectPortal>
|
||||
<SelectContent class="bg-bg-elv border border-div rounded-md shadow-lg z-50 max-h-60 overflow-hidden z-9999"
|
||||
:side-offset="4">
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem v-for="color in colorOptions" :key="color" :value="color"
|
||||
class="relative flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-bg-alt rounded-sm outline-none data-[highlighted]:bg-bg-alt data-[state=checked]:bg-bg-alt data-[state=checked]:text-text">
|
||||
<span class="inline-block w-4 h-4 rounded-full border border-div"
|
||||
:style="{ backgroundColor: colors[color][500] }" />
|
||||
<SelectItemText>
|
||||
{{ normalizeColorName(color) }}
|
||||
</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2">
|
||||
<span class="i-lucide:check w-4 h-4 text-text-2" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
</template>
|
|
@ -1,363 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { FeedbackType } from '../../types/Feedback'
|
||||
import { useRouter } from 'vitepress'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { feedbackOptions, getFeedbackOption } from '../../types/Feedback'
|
||||
|
||||
const props = defineProps<{
|
||||
heading?: string
|
||||
}>()
|
||||
|
||||
const prompts = [
|
||||
'Make it count!',
|
||||
'Leave some feedback for us!',
|
||||
`We're all ears 🐰`,
|
||||
'Tell us what is missing in FMHY',
|
||||
'Your thoughts matter to us 💡',
|
||||
'Feedback is a gift 🎁',
|
||||
'What do you think?',
|
||||
'We appreciate your support 🙏',
|
||||
'Help us make FMHY better 🤝',
|
||||
'We need your help 👋',
|
||||
'Your feedback is valuable 💯',
|
||||
'So... what do you think?',
|
||||
"I guess you don't need to say anything 😉",
|
||||
'Spill the beans 💣',
|
||||
"We're always looking for ways to improve!",
|
||||
'Your feedback is valuable and helps us make FMHY better.',
|
||||
'aliens are watching you 👽',
|
||||
'tasky was here 👀',
|
||||
'The internet is full of crap 😱'
|
||||
]
|
||||
|
||||
function getPrompt() {
|
||||
return prompts[Math.floor(Math.random() * prompts.length)]
|
||||
}
|
||||
|
||||
const messages = {
|
||||
suggestion: [
|
||||
"We're glad you want to share your ideas!",
|
||||
'Nix the fluff and just tell us what you think!',
|
||||
"We'll be happy to read your thoughts and incorporate them into our wiki.",
|
||||
"Hello! We're glad you want to share your ideas!"
|
||||
],
|
||||
appreciation: [
|
||||
'We appreciate your support!',
|
||||
"We're always looking for ways to improve!.",
|
||||
'Your feedback is valuable and helps us make FMHY better.'
|
||||
],
|
||||
other: [
|
||||
"We're always looking for ways to improve!",
|
||||
'Your feedback is valuable and helps us make FMHY better.'
|
||||
]
|
||||
}
|
||||
|
||||
function getMessage(type: FeedbackType['type']) {
|
||||
return messages[type][Math.floor(Math.random() * messages[type].length)]
|
||||
}
|
||||
|
||||
const loading = ref<boolean>(false)
|
||||
const error = ref<unknown>(null)
|
||||
const success = ref<boolean>(false)
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
return (
|
||||
!feedback.message.length ||
|
||||
feedback.message.length < 5 ||
|
||||
feedback.message.length > 1000
|
||||
)
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const feedback = reactive<{
|
||||
message: string
|
||||
page: string
|
||||
type?: FeedbackType['type']
|
||||
}>({
|
||||
page: router.route.path,
|
||||
message: ''
|
||||
})
|
||||
|
||||
const selectedOption = ref(feedbackOptions[0])
|
||||
|
||||
async function handleSubmit(type?: FeedbackType['type']) {
|
||||
if (type) {
|
||||
feedback.type = type
|
||||
selectedOption.value = getFeedbackOption(type)!
|
||||
}
|
||||
loading.value = true
|
||||
|
||||
const body: FeedbackType = {
|
||||
message: feedback.message,
|
||||
type: feedback.type!,
|
||||
page: feedback.page,
|
||||
...(props.heading && { heading: props.heading })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.fmhy.net/feedback', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
if (data.error) {
|
||||
error.value = data.error
|
||||
return
|
||||
}
|
||||
if (data.status === 'ok') {
|
||||
success.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isCardShown = ref<boolean>(false)
|
||||
const helpfulText = props.heading
|
||||
? 'What do you think about this section?'
|
||||
: 'What do you think about this page?'
|
||||
const helpfulDescription = props.heading
|
||||
? 'Let us know how helpful this section is.'
|
||||
: 'Let us know how helpful this page is.'
|
||||
|
||||
const prompt = computed(() => getPrompt())
|
||||
const message = computed(() => getMessage(feedback.type!))
|
||||
const toggleCard = () => (isCardShown.value = !isCardShown.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="props.heading">
|
||||
<button
|
||||
@click="toggleCard()"
|
||||
class="bg-$vp-c-default-soft text-primary border-$vp-c-default-soft hover:border-primary ml-3 inline-flex h-7 items-center justify-center whitespace-nowrap rounded-md border-2 border-solid px-1.5 py-3.5 text-sm font-medium transition-all duration-300 sm:h-6"
|
||||
>
|
||||
<span
|
||||
:class="isCardShown === false ? `i-lucide:mail` : `i-lucide:mail-x`"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="mt-2 p-4 border-2 border-solid bg-brand-50 border-brand-300 dark:bg-brand-950 dark:border-brand-800 rounded-xl col-span-3 transition-colors duration-250"
|
||||
>
|
||||
<div class="flex items-start md:items-center gap-3">
|
||||
<div class="pt-1 md:pt-0">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center bg-brand-500 dark:bg-brand-400"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
isCardShown === false
|
||||
? `i-lucide:mail w-6 h-6 text-white dark:text-brand-950`
|
||||
: `i-lucide:mail-x w-6 h-6 text-white dark:text-brand-950`
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex-grow flex items-start md:items-center gap-3 flex-col md:flex-row"
|
||||
>
|
||||
<div class="flex-grow">
|
||||
<div class="font-semibold text-brand-950 dark:text-brand-50">
|
||||
Got feedback?
|
||||
</div>
|
||||
<div class="text-sm text-brand-800 dark:text-brand-100">
|
||||
We'd love to know what you think about this page.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="inline-block text-center rounded-full px-4 py-2.5 text-sm font-medium border-2 border-solid text-brand-700 border-brand-300 dark:text-brand-100 dark:border-brand-800"
|
||||
@click="toggleCard()"
|
||||
>
|
||||
Share Feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="isCardShown"
|
||||
class="border-$vp-c-divider bg-$vp-c-bg-alt b-rd-4 m-[2rem 0] mt-4 border-2 border-solid p-6"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="!feedback.type">
|
||||
<p class="heading">
|
||||
{{ helpfulText }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="item in feedbackOptions"
|
||||
:key="item.value"
|
||||
class="bg-bg border-$vp-c-default-soft hover:border-primary mt-2 select-none rounded border-2 border-solid font-bold transition-all duration-250 rounded-lg text-[14px] font-500 leading-normal m-0 px-3 py-1.5 text-center align-middle whitespace-nowrap"
|
||||
@click="handleSubmit(item.value)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="feedback.type && !success">
|
||||
<div>
|
||||
<p class="desc">{{ helpfulDescription }} - {{ prompt }}</p>
|
||||
<span>{{ getFeedbackOption(feedback.type)?.label }}</span>
|
||||
</div>
|
||||
<p class="heading" v-text="message"></p>
|
||||
<div v-if="feedback.type === 'suggestion'" class="mb-2 text-sm">
|
||||
<details>
|
||||
<summary>
|
||||
<span class="ii-lucide-shield-x bg-cerise-400 mb-1 ml-1" />
|
||||
Do not submit any of the following:
|
||||
</summary>
|
||||
<strong>🕹️ Emulators</strong>
|
||||
<p class="desc">
|
||||
They're already on the
|
||||
<a
|
||||
class="text-primary text-underline font-bold"
|
||||
href="https://emulation.gametechwiki.com/index.php/Main_Page"
|
||||
>
|
||||
Game Tech Wiki.
|
||||
</a>
|
||||
</p>
|
||||
<strong>🔻 Leeches</strong>
|
||||
<p class="desc">
|
||||
They're already on the
|
||||
<a
|
||||
class="text-primary text-underline font-bold"
|
||||
href="https://filehostlist.miraheze.org/wiki/Free_Premium_Leeches"
|
||||
>
|
||||
File Hosting Wiki.
|
||||
</a>
|
||||
</p>
|
||||
<strong>🐧 Distros</strong>
|
||||
<p class="desc">
|
||||
They're already on
|
||||
<a
|
||||
class="text-primary text-underline font-bold"
|
||||
href="https://distrowatch.com/"
|
||||
>
|
||||
DistroWatch.
|
||||
</a>
|
||||
</p>
|
||||
<strong>🎲 Mining / Betting Sites</strong>
|
||||
<p class="desc">
|
||||
Don't post anything related to betting, mining, BINs, CCs, etc.
|
||||
</p>
|
||||
<strong>🎮 Multiplayer Game Hacks</strong>
|
||||
<p class="desc">
|
||||
Don't post any hacks/exploits that give unfair advantages in
|
||||
multiplayer games.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="feedback.message"
|
||||
autofocus
|
||||
class="bg-$vp-c-bg-alt text-$vp-c-text-2 w-full h-[100px] border border-$vp-c-divider rounded px-3 py-1.5 border-$vp-c-divider bg-$vp-c-bg-alt b-rd-4 border-2 border-solid"
|
||||
placeholder="What a lovely wiki!"
|
||||
/>
|
||||
<p class="desc mb-2">
|
||||
Add your Discord handle if you would like a response, or if we need
|
||||
more information from you, otherwise join our
|
||||
<a
|
||||
class="text-primary text-underline font-semibold"
|
||||
href="https://rentry.co/FMHY-Invite/"
|
||||
>
|
||||
Discord.
|
||||
</a>
|
||||
</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<button
|
||||
class="bg-$vp-c-default-soft text-primary border-$vp-c-default-soft inline-flex h-7 items-center justify-center whitespace-nowrap rounded-md border-2 border-solid px-1.5 py-3.5 text-sm font-medium transition-all duration-300 sm:h-6"
|
||||
@click="feedback.type = undefined"
|
||||
>
|
||||
<span class="i-lucide:panel-left-close">close</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-div rounded-lg transition-colors duration-250 inline-block text-14px font-500 leading-1.5 px-3 py-3 text-center align-middle whitespace-nowrap disabled:opacity-50 text-text-2 bg-brand-100 dark:bg-brand-700 border-brand-800 dark:border-brand-700 disabled:bg-brand-100 dark:disabled:bg-brand-900 hover:border-brand-900 dark:hover:border-brand-800 hover:bg-brand-200 dark:hover:bg-brand-800"
|
||||
:disabled="isDisabled"
|
||||
@click="handleSubmit()"
|
||||
>
|
||||
Send Feedback 📩
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="heading">Thanks for your feedback!</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="css">
|
||||
.btn {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
border-color 0.25s,
|
||||
background-color 0.25s;
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 0.375rem 0.75rem;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--vp-c-brand-darker);
|
||||
border-color: var(--vp-c-brand-darker);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desc {
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
|
@ -1,47 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
id: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="input-field">
|
||||
<div v-if="label" class="input-label">
|
||||
<label :for="id" class="pane-label">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="display-value">
|
||||
<slot name="display" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane-label {
|
||||
line-height: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vt-c-text-1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.input-field:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.display-value {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
|
@ -1,30 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Field from './CardField.vue'
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
import InputField from './InputField.vue'
|
||||
import ToggleStarred from './ToggleStarred.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-$vp-c-bg border-$vp-c-default-soft hover:border-primary transition-border relative z-0 rounded-lg border-2 border-solid p-5 duration-500">
|
||||
<div class="align-center mb-4 flex justify-between">
|
||||
<div class="text-$vp-c-text-1 lh-relaxed text-sm font-bold">
|
||||
Emoji Legend
|
||||
</div>
|
||||
</div>
|
||||
<Field icon="i-twemoji-globe-with-meridians">Indexes</Field>
|
||||
<Field icon="i-twemoji-repeat-button">Storage Links</Field>
|
||||
<Field icon="i-twemoji-star">Recommendations</Field>
|
||||
<div class="align-center mb-4 mt-4 flex justify-between">
|
||||
<div class="text-$vp-c-text-1 lh-relaxed text-sm font-bold">Options</div>
|
||||
</div>
|
||||
<InputField id="toggle-starred" label="Toggle Starred">
|
||||
<template #display>
|
||||
<ToggleStarred />
|
||||
</template>
|
||||
</InputField>
|
||||
|
||||
<ColorPicker />
|
||||
</div>
|
||||
</template>
|
|
@ -1,52 +0,0 @@
|
|||
<script setup>
|
||||
import { Switch } from '@headlessui/vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const enabled = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch v-model="enabled" class="switch" :class="{ enabled }">
|
||||
<span class="thumb" />
|
||||
</Switch>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--vp-input-border-color);
|
||||
background-color: var(--vp-input-switch-bg-color);
|
||||
transition:
|
||||
border-color 0.25s,
|
||||
background-color 0.4s ease;
|
||||
border-radius: 11px;
|
||||
}
|
||||
|
||||
.switch.enabled {
|
||||
background-color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.switch:hover {
|
||||
border-color: var(--vp-input-hover-border-color);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: inline-block;
|
||||
background-color: #fff;
|
||||
transition: transform 0.25s;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--vp-shadow-1);
|
||||
}
|
||||
|
||||
.switch.enabled .thumb {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
</style>
|
|
@ -1,16 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Switch from './Switch.vue'
|
||||
|
||||
const toggleStarred = () =>
|
||||
document.documentElement.classList.toggle('starred-only')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Switch @click="toggleStarred()" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.starred-only li:not(.starred) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
|
@ -1,346 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const KNOWN_EXTENSIONS = new Set()
|
||||
|
||||
function treatAsHtml(filename: string): boolean {
|
||||
if (KNOWN_EXTENSIONS.size === 0) {
|
||||
const extraExts =
|
||||
(typeof process === 'object' && process.env?.VITE_EXTRA_EXTENSIONS) ||
|
||||
(import.meta as any).env?.VITE_EXTRA_EXTENSIONS ||
|
||||
''
|
||||
|
||||
// md, html? are intentionally omitted
|
||||
; (
|
||||
'3g2,3gp,aac,ai,apng,au,avif,bin,bmp,cer,class,conf,crl,css,csv,dll,' +
|
||||
'doc,eps,epub,exe,gif,gz,ics,ief,jar,jpe,jpeg,jpg,js,json,jsonld,m4a,' +
|
||||
'man,mid,midi,mjs,mov,mp2,mp3,mp4,mpe,mpeg,mpg,mpp,oga,ogg,ogv,ogx,' +
|
||||
'opus,otf,p10,p7c,p7m,p7s,pdf,png,ps,qt,roff,rtf,rtx,ser,svg,t,tif,' +
|
||||
'tiff,tr,ts,tsv,ttf,txt,vtt,wav,weba,webm,webp,woff,woff2,xhtml,xml,' +
|
||||
'yaml,yml,zip' +
|
||||
(extraExts && typeof extraExts === 'string' ? ',' + extraExts : '')
|
||||
)
|
||||
.split(',')
|
||||
.forEach((ext) => KNOWN_EXTENSIONS.add(ext))
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()
|
||||
|
||||
return ext == null || !KNOWN_EXTENSIONS.has(ext.toLowerCase())
|
||||
}
|
||||
|
||||
const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i
|
||||
|
||||
function normalizeLink(url: string): string {
|
||||
const { pathname, search, hash, protocol } = new URL(url, 'http://a.com')
|
||||
function isExternal(path: string): boolean {
|
||||
return EXTERNAL_URL_RE.test(path)
|
||||
}
|
||||
|
||||
if (
|
||||
isExternal(url) ||
|
||||
url.startsWith('#') ||
|
||||
!protocol.startsWith('http') ||
|
||||
!treatAsHtml(pathname)
|
||||
)
|
||||
return url
|
||||
|
||||
const { site } = useData()
|
||||
|
||||
const normalizedPath =
|
||||
pathname.endsWith('/') || pathname.endsWith('.html')
|
||||
? url
|
||||
: url.replace(
|
||||
/(?:(^\.+)\/)?.*$/,
|
||||
`$1${pathname.replace(
|
||||
/(\.md)?$/,
|
||||
site.value.cleanUrls ? '' : '.html'
|
||||
)}${search}${hash}`
|
||||
)
|
||||
|
||||
return withBase(normalizedPath)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tag?: string
|
||||
size?: 'medium' | 'big'
|
||||
theme?: 'brand' | 'alt' | 'sponsor'
|
||||
text?: string
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'medium',
|
||||
theme: 'brand'
|
||||
})
|
||||
|
||||
const isExternal = computed(
|
||||
() => props.href && EXTERNAL_URL_RE.test(props.href)
|
||||
)
|
||||
|
||||
const component = computed(() => {
|
||||
return props.tag || (props.href ? 'a' : 'button')
|
||||
})
|
||||
|
||||
// Dropdown functionality
|
||||
const isDropdownOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement>()
|
||||
|
||||
const toggleDropdown = (e: Event) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
isDropdownOpen.value = !isDropdownOpen.value
|
||||
}
|
||||
|
||||
const closeDropdown = () => {
|
||||
isDropdownOpen.value = false
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: Event) => {
|
||||
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
const processedItems = computed(() => {
|
||||
return FMHY_HOMEPAGE_ITEMS.map((item: any) => {
|
||||
if ('items' in item && item.items) {
|
||||
return {
|
||||
...item,
|
||||
items: item.items.map((subItem: any) => ({
|
||||
...subItem,
|
||||
displayText: subItem.text || ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
displayText: item.text || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="theme === 'brand'" class="VPButtonDropdown" ref="dropdownRef">
|
||||
<div class="VPButtonWrapper">
|
||||
<component :is="component" class="VPButton VPButtonMain" :class="[size, theme]"
|
||||
:href="href ? normalizeLink(href) : undefined" :target="props.target ?? (isExternal ? '_blank' : undefined)"
|
||||
:rel="props.rel ?? (isExternal ? 'noreferrer' : undefined)">
|
||||
<slot>{{ text }}</slot>
|
||||
</component>
|
||||
<button
|
||||
class="bg-$vp-c-default-soft text-text border-$vp-c-default-soft hover:border-primary ml-3 inline-flex h-8 items-center justify-center whitespace-nowrap rounded-md border-2 border-solid px-2.5 py-4.5 text-md font-medium transition-all duration-300 sm:h-7 VPButtonTrigger"
|
||||
@click="toggleDropdown" :aria-expanded="isDropdownOpen" aria-label="Toggle dropdown menu">
|
||||
<span class="i-lucide:menu"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isDropdownOpen" class="VPButtonDropdownMenu">
|
||||
<div class="VPButtonDropdownContent">
|
||||
<template v-for="item in processedItems" :key="item.text">
|
||||
<div v-if="'items' in item" class="VPButtonDropdownSection">
|
||||
<div class="VPButtonDropdownSectionTitle" v-html="item.text"></div>
|
||||
<a v-for="subItem in item.items" :key="subItem.text" :href="subItem.link" class="VPButtonDropdownItem"
|
||||
@click="closeDropdown">
|
||||
<span v-html="subItem.displayText"></span>
|
||||
</a>
|
||||
</div>
|
||||
<a v-else :href="item.link" class="VPButtonDropdownItem" @click="closeDropdown">
|
||||
<span v-html="item.displayText"></span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component v-else :is="component" class="VPButton" :class="[size, theme]"
|
||||
:href="href ? normalizeLink(href) : undefined" :target="props.target ?? (isExternal ? '_blank' : undefined)"
|
||||
:rel="props.rel ?? (isExternal ? 'noreferrer' : undefined)">
|
||||
<slot>{{ text }}</slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.VPButton {
|
||||
display: inline-block;
|
||||
border: 1px solid transparent;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: color 0.25s, border-color 0.25s, background-color 0.25s;
|
||||
}
|
||||
|
||||
.VPButton:active {
|
||||
transition: color 0.1s, border-color 0.1s, background-color 0.1s;
|
||||
}
|
||||
|
||||
.VPButton.medium {
|
||||
border-radius: 20px;
|
||||
padding: 0 20px;
|
||||
line-height: 38px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.VPButton.big {
|
||||
border-radius: 24px;
|
||||
padding: 0 24px;
|
||||
line-height: 46px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.VPButton.brand {
|
||||
border-color: var(--vp-button-brand-border);
|
||||
color: var(--vp-button-brand-text);
|
||||
background-color: var(--vp-button-brand-bg);
|
||||
}
|
||||
|
||||
.VPButton.brand:hover {
|
||||
border-color: var(--vp-button-brand-hover-border);
|
||||
color: var(--vp-button-brand-hover-text);
|
||||
background-color: var(--vp-button-brand-hover-bg);
|
||||
}
|
||||
|
||||
.VPButton.brand:active {
|
||||
border-color: var(--vp-button-brand-active-border);
|
||||
color: var(--vp-button-brand-active-text);
|
||||
background-color: var(--vp-button-brand-active-bg);
|
||||
}
|
||||
|
||||
.VPButton.alt {
|
||||
border-color: var(--vp-button-alt-border);
|
||||
color: var(--vp-button-alt-text);
|
||||
background-color: var(--vp-button-alt-bg);
|
||||
}
|
||||
|
||||
.VPButton.alt:hover {
|
||||
border-color: var(--vp-button-alt-hover-border);
|
||||
color: var(--vp-button-alt-hover-text);
|
||||
background-color: var(--vp-button-alt-hover-bg);
|
||||
}
|
||||
|
||||
.VPButton.alt:active {
|
||||
border-color: var(--vp-button-alt-active-border);
|
||||
color: var(--vp-button-alt-active-text);
|
||||
background-color: var(--vp-button-alt-active-bg);
|
||||
}
|
||||
|
||||
.VPButton.sponsor {
|
||||
border-color: var(--vp-button-sponsor-border);
|
||||
color: var(--vp-button-sponsor-text);
|
||||
background-color: var(--vp-button-sponsor-bg);
|
||||
}
|
||||
|
||||
.VPButton.sponsor:hover {
|
||||
border-color: var(--vp-button-sponsor-hover-border);
|
||||
color: var(--vp-button-sponsor-hover-text);
|
||||
background-color: var(--vp-button-sponsor-hover-bg);
|
||||
}
|
||||
|
||||
.VPButton.sponsor:active {
|
||||
border-color: var(--vp-button-sponsor-active-border);
|
||||
color: var(--vp-button-sponsor-active-text);
|
||||
background-color: var(--vp-button-sponsor-active-bg);
|
||||
}
|
||||
|
||||
/* Dropdown styles */
|
||||
.VPButtonDropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.VPButtonWrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.VPButtonMain {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.VPButtonDropdownMenu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
margin-top: 4px;
|
||||
background-color: var(--vp-c-bg-elv);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
min-width: 280px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.VPButtonDropdownContent {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.VPButtonDropdownSection {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.VPButtonDropdownSection:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.VPButtonDropdownSectionTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
padding: 8px 12px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.VPButtonDropdownItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--vp-c-text-1);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.VPButtonDropdownItem:hover {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
.VPButtonDropdownIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.VPButtonDropdownItem span :deep([class*="i-"]) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.VPButtonDropdownSectionTitle :deep([class*="i-"]) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
|
@ -1,37 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
mobile: string
|
||||
desktop: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-bg-elv text-text w-full max-w-md rounded-lg border shadow-sm">
|
||||
<div class="flex flex-col space-y-1.5 p-6 pb-4">
|
||||
<h4
|
||||
class="whitespace-nowrap text-2xl font-semibold leading-none tracking-tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h4>
|
||||
<p class="text-text-2 text-sm font-medium leading-none tracking-tight">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<img
|
||||
:src="desktop"
|
||||
:alt="title"
|
||||
class="aspect-[3/2] rounded-md object-cover"
|
||||
width="1080"
|
||||
height="1920"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-6">
|
||||
<a :href="mobile" target="_blank">Mobile</a>
|
||||
<span class="text-text-2">•</span>
|
||||
<a :href="desktop" target="_blank">Desktop</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,733 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from 'reka-ui'
|
||||
import { useRouter } from 'vitepress'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
export type BookmarkType = {
|
||||
name: string
|
||||
chord: string
|
||||
url: string
|
||||
icon?: string // e.g. 'i-logos:github'
|
||||
color?: string
|
||||
isCustom?: boolean // Track if it's a custom bookmark
|
||||
customSvg?: string // Store custom SVG icon
|
||||
}
|
||||
|
||||
const props = defineProps<{ isInputGloballyFocused: boolean }>()
|
||||
|
||||
const currentChordInput = ref('')
|
||||
const activePossibleChords = ref<BookmarkType[]>([])
|
||||
const customBookmarks = ref<BookmarkType[]>([])
|
||||
const allBookmarks = ref<BookmarkType[]>([])
|
||||
|
||||
// Dialog states
|
||||
const isAddDialogOpen = ref(false)
|
||||
const isEditDialogOpen = ref(false)
|
||||
const isDeleteDialogOpen = ref(false)
|
||||
|
||||
// Form states
|
||||
const formData = ref<BookmarkType>({
|
||||
name: '',
|
||||
chord: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
color: '',
|
||||
isCustom: true,
|
||||
customSvg: ''
|
||||
})
|
||||
|
||||
const editingBookmark = ref<BookmarkType | null>(null)
|
||||
const deletingBookmark = ref<BookmarkType | null>(null)
|
||||
|
||||
let chordTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const initialBookmarksData: BookmarkType[] = [
|
||||
{
|
||||
name: 'Hacker News',
|
||||
chord: 'HN',
|
||||
url: 'https://news.ycombinator.com/',
|
||||
icon: 'i-logos:ycombinator'
|
||||
},
|
||||
{
|
||||
name: 'GitHub',
|
||||
chord: 'GH',
|
||||
url: 'https://github.com/',
|
||||
icon: 'i-simple-icons:github'
|
||||
},
|
||||
{
|
||||
name: 'Reddit',
|
||||
chord: 'RD',
|
||||
url: 'https://reddit.com/',
|
||||
icon: 'i-logos:reddit-icon'
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
chord: 'TW',
|
||||
url: 'https://twitter.com/',
|
||||
icon: 'i-logos:twitter'
|
||||
},
|
||||
{
|
||||
name: 'YouTube',
|
||||
chord: 'YT',
|
||||
url: 'https://youtube.com/',
|
||||
icon: 'i-logos:youtube-icon'
|
||||
},
|
||||
{
|
||||
name: 'Wikipedia',
|
||||
chord: 'WK',
|
||||
url: 'https://wikipedia.org/',
|
||||
icon: 'i-simple-icons:wikipedia'
|
||||
},
|
||||
{
|
||||
name: "Beginner's Guide",
|
||||
chord: 'BG',
|
||||
url: '/beginners-guide',
|
||||
icon: 'i-lucide:book-open-text'
|
||||
},
|
||||
{
|
||||
name: 'Wotaku',
|
||||
chord: 'WT',
|
||||
url: 'https://wotaku.wiki/',
|
||||
icon: 'i-twemoji:flag-japan'
|
||||
},
|
||||
{
|
||||
name: 'privateersclub',
|
||||
chord: 'PC',
|
||||
url: 'https://megathread.pages.dev/',
|
||||
icon: 'i-custom:privateersclub'
|
||||
}
|
||||
]
|
||||
|
||||
// Load custom bookmarks from localStorage
|
||||
const loadCustomBookmarks = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('customBookmarks')
|
||||
if (stored) {
|
||||
customBookmarks.value = JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading custom bookmarks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Save custom bookmarks to localStorage
|
||||
const saveCustomBookmarks = () => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'customBookmarks',
|
||||
JSON.stringify(customBookmarks.value)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error saving custom bookmarks:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update all bookmarks when custom bookmarks change
|
||||
const updateAllBookmarks = () => {
|
||||
allBookmarks.value = [...initialBookmarksData, ...customBookmarks.value]
|
||||
}
|
||||
|
||||
// Watch for changes in custom bookmarks
|
||||
watch(
|
||||
customBookmarks,
|
||||
() => {
|
||||
updateAllBookmarks()
|
||||
saveCustomBookmarks()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const resetChord = () => {
|
||||
currentChordInput.value = ''
|
||||
activePossibleChords.value = []
|
||||
if (chordTimeout) clearTimeout(chordTimeout)
|
||||
chordTimeout = null
|
||||
}
|
||||
|
||||
const handleBookmarkClick = (bookmark: BookmarkType) => {
|
||||
if (bookmark.url.startsWith('/')) {
|
||||
router.go(bookmark.url)
|
||||
} else {
|
||||
window.open(bookmark.url, '_self')
|
||||
}
|
||||
}
|
||||
|
||||
// Form validation
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
formData.value.name.trim() &&
|
||||
formData.value.chord.trim() &&
|
||||
formData.value.url.trim() &&
|
||||
!isChordTaken(formData.value.chord, editingBookmark.value?.chord)
|
||||
)
|
||||
}
|
||||
|
||||
const isChordTaken = (chord: string, excludeChord?: string) => {
|
||||
return allBookmarks.value.some(
|
||||
(b) =>
|
||||
b.chord.toUpperCase() === chord.toUpperCase() && b.chord !== excludeChord
|
||||
)
|
||||
}
|
||||
|
||||
// Reset form
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
chord: '',
|
||||
url: '',
|
||||
icon: '',
|
||||
color: '',
|
||||
isCustom: true,
|
||||
customSvg: ''
|
||||
}
|
||||
}
|
||||
|
||||
// Add bookmark
|
||||
const handleAddBookmark = () => {
|
||||
if (!isFormValid()) return
|
||||
|
||||
const newBookmark: BookmarkType = {
|
||||
...formData.value,
|
||||
chord: formData.value.chord.toUpperCase(),
|
||||
isCustom: true
|
||||
}
|
||||
|
||||
// If no icon and no custom SVG, use default website icon
|
||||
if (!newBookmark.icon && !newBookmark.customSvg) {
|
||||
newBookmark.icon = 'i-lucide:globe'
|
||||
}
|
||||
|
||||
customBookmarks.value.push(newBookmark)
|
||||
isAddDialogOpen.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// Edit bookmark
|
||||
const openEditDialog = (bookmark: BookmarkType) => {
|
||||
editingBookmark.value = bookmark
|
||||
formData.value = { ...bookmark }
|
||||
isEditDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleEditBookmark = () => {
|
||||
if (!isFormValid() || !editingBookmark.value) return
|
||||
|
||||
const index = customBookmarks.value.findIndex(
|
||||
(b) => b === editingBookmark.value
|
||||
)
|
||||
if (index !== -1) {
|
||||
customBookmarks.value[index] = {
|
||||
...formData.value,
|
||||
chord: formData.value.chord.toUpperCase(),
|
||||
isCustom: true
|
||||
}
|
||||
|
||||
// If no icon and no custom SVG, use default website icon
|
||||
if (
|
||||
!customBookmarks.value[index].icon &&
|
||||
!customBookmarks.value[index].customSvg
|
||||
) {
|
||||
customBookmarks.value[index].icon = 'i-lucide:globe'
|
||||
}
|
||||
}
|
||||
|
||||
isEditDialogOpen.value = false
|
||||
editingBookmark.value = null
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// Delete bookmark
|
||||
const openDeleteDialog = (bookmark: BookmarkType) => {
|
||||
deletingBookmark.value = bookmark
|
||||
isDeleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteBookmark = () => {
|
||||
if (!deletingBookmark.value) return
|
||||
|
||||
const index = customBookmarks.value.findIndex(
|
||||
(b) => b === deletingBookmark.value
|
||||
)
|
||||
if (index !== -1) {
|
||||
customBookmarks.value.splice(index, 1)
|
||||
}
|
||||
|
||||
isDeleteDialogOpen.value = false
|
||||
deletingBookmark.value = null
|
||||
}
|
||||
|
||||
// Handle SVG input
|
||||
const handleSvgInput = (event: Event) => {
|
||||
const target = event.target as HTMLTextAreaElement
|
||||
formData.value.customSvg = target.value
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
props.isInputGloballyFocused ||
|
||||
e.altKey ||
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.shiftKey
|
||||
)
|
||||
return
|
||||
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (
|
||||
active?.tagName === 'INPUT' ||
|
||||
active?.tagName === 'TEXTAREA' ||
|
||||
active?.isContentEditable
|
||||
)
|
||||
return
|
||||
|
||||
const key = e.key.toUpperCase()
|
||||
if (chordTimeout) clearTimeout(chordTimeout)
|
||||
|
||||
if (!currentChordInput.value) {
|
||||
const matches = allBookmarks.value.filter((b) => b.chord.startsWith(key))
|
||||
if (matches.length) {
|
||||
e.preventDefault()
|
||||
currentChordInput.value = key
|
||||
activePossibleChords.value = matches
|
||||
chordTimeout = setTimeout(resetChord, 2000)
|
||||
}
|
||||
} else {
|
||||
const next = currentChordInput.value + key
|
||||
const match = activePossibleChords.value.find((b) => b.chord === next)
|
||||
if (match) {
|
||||
if (match.url.startsWith('/')) {
|
||||
router.go(match.url)
|
||||
} else {
|
||||
window.open(match.url, '_self')
|
||||
}
|
||||
resetChord()
|
||||
} else {
|
||||
const filtered = allBookmarks.value.filter((b) =>
|
||||
b.chord.startsWith(next)
|
||||
)
|
||||
if (filtered.length) {
|
||||
currentChordInput.value = next
|
||||
activePossibleChords.value = filtered
|
||||
chordTimeout = setTimeout(resetChord, 2000)
|
||||
} else {
|
||||
resetChord()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomBookmarks()
|
||||
updateAllBookmarks()
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
if (chordTimeout) clearTimeout(chordTimeout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-text-2">
|
||||
<i class="i-lucide:bookmark w-5 h-5" />
|
||||
<h2 class="text-xl">Bookmarks</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="currentChordInput"
|
||||
class="px-3 py-1 rounded-md text-sm font-medium bg-yellow-200/20 text-yellow-600"
|
||||
>
|
||||
Chord: {{ currentChordInput }}...
|
||||
</div>
|
||||
|
||||
<!-- Add Bookmark Button -->
|
||||
<DialogRoot v-model:open="isAddDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-bg-alt text-white hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<i class="i-lucide:plus w-4 h-4" />
|
||||
Add Bookmark
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 bg-black/50 z-50" />
|
||||
<DialogContent
|
||||
description="Add New Bookmark"
|
||||
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-bg border border-div rounded-lg p-6 w-full max-w-md z-50 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<DialogTitle class="text-lg font-semibold text-text mb-4">
|
||||
Add New Bookmark
|
||||
</DialogTitle>
|
||||
<DialogDescription class="text-text-2 mb-6">
|
||||
Add a new bookmark
|
||||
</DialogDescription>
|
||||
<form @submit.prevent="handleAddBookmark" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="Bookmark name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Chord (2 letters)
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.chord"
|
||||
type="text"
|
||||
required
|
||||
maxlength="2"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none uppercase"
|
||||
placeholder="AB"
|
||||
@input="
|
||||
(e) =>
|
||||
(formData.chord = (
|
||||
e.target as HTMLInputElement
|
||||
).value.toUpperCase())
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="isChordTaken(formData.chord)"
|
||||
class="text-red-500 text-xs mt-1"
|
||||
>
|
||||
This chord is already taken
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.url"
|
||||
type="url"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Icon (UnoCSS class)
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.icon"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="i-lucide:globe (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Custom SVG Icon
|
||||
</label>
|
||||
<textarea
|
||||
v-model="formData.customSvg"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none h-20 resize-none"
|
||||
placeholder="Paste SVG code here (optional)"
|
||||
/>
|
||||
<p class="text-xs text-text-2 mt-1">
|
||||
If provided, this will override the icon class
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.color"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="#3B82F6 (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!isFormValid()"
|
||||
class="flex-1 bg-primary text-white py-2 px-4 rounded-md font-medium hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
>
|
||||
Add Bookmark
|
||||
</button>
|
||||
<DialogClose as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 bg-bg-alt text-text py-2 px-4 rounded-md font-medium border border-div hover:bg-bg-elv transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="bookmark in allBookmarks"
|
||||
:key="bookmark.name"
|
||||
class="relative group"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'w-full rounded-md border border-div bg-bg-alt px-3 py-2 text-left transition-opacity duration-150',
|
||||
activePossibleChords.some((ab) => ab.chord === bookmark.chord)
|
||||
? bookmark.chord === currentChordInput
|
||||
? 'opacity-100 ring-2 ring-primary ring-offset-2 ring-offset-bg'
|
||||
: 'opacity-75'
|
||||
: currentChordInput
|
||||
? 'opacity-30'
|
||||
: 'opacity-100'
|
||||
]"
|
||||
@click="handleBookmarkClick(bookmark)"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-3 w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<!-- Custom SVG Icon -->
|
||||
<div
|
||||
v-if="bookmark.customSvg"
|
||||
class="shrink-0 w-4 h-4"
|
||||
v-html="bookmark.customSvg"
|
||||
/>
|
||||
<!-- Regular Icon -->
|
||||
<i
|
||||
v-else-if="bookmark.icon"
|
||||
:class="`shrink-0 w-4 h-4 ${bookmark.icon}`"
|
||||
:style="bookmark.color ? { color: bookmark.color } : {}"
|
||||
/>
|
||||
<!-- Fallback Icon -->
|
||||
<i v-else class="shrink-0 w-4 h-4 i-lucide:globe" />
|
||||
|
||||
<span class="truncate font-medium">{{ bookmark.name }}</span>
|
||||
</div>
|
||||
<div class="hidden sm:flex text-xs items-center gap-1 text-text-2">
|
||||
<kbd
|
||||
v-for="(char, i) in bookmark.chord.split('')"
|
||||
:key="i"
|
||||
class="bg-bg border border-div px-1 py-0.5 rounded text-sm font-semibold"
|
||||
>
|
||||
{{ char }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Edit/Delete buttons for custom bookmarks -->
|
||||
<div
|
||||
v-if="bookmark.isCustom"
|
||||
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"
|
||||
>
|
||||
<button
|
||||
@click.stop="openEditDialog(bookmark)"
|
||||
class="p-1 bg-bg-elv border border-div rounded hover:bg-bg-alt transition-colors"
|
||||
title="Edit bookmark"
|
||||
>
|
||||
<i class="i-lucide:edit-2 w-3 h-3 text-text-2" />
|
||||
</button>
|
||||
<button
|
||||
@click.stop="openDeleteDialog(bookmark)"
|
||||
class="p-1 bg-bg-elv border border-div rounded hover:bg-red-100 hover:text-red-600 transition-colors"
|
||||
title="Delete bookmark"
|
||||
>
|
||||
<i class="i-lucide:trash-2 w-3 h-3 text-text-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<DialogRoot v-model:open="isEditDialogOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 bg-black/50 z-50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-bg border border-div rounded-lg p-6 w-full max-w-md z-50 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<DialogTitle class="text-lg font-semibold text-text mb-4">
|
||||
Edit Bookmark
|
||||
</DialogTitle>
|
||||
<DialogDescription class="text-text-2 mb-6">
|
||||
Editing "{{ editingBookmark?.name }}"
|
||||
</DialogDescription>
|
||||
<form @submit.prevent="handleEditBookmark" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="Bookmark name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Chord (2 letters)
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.chord"
|
||||
type="text"
|
||||
required
|
||||
maxlength="2"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none uppercase"
|
||||
placeholder="AB"
|
||||
@input="
|
||||
(e) =>
|
||||
(formData.chord = (
|
||||
e.target as HTMLInputElement
|
||||
).value.toUpperCase())
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="isChordTaken(formData.chord, editingBookmark?.chord)"
|
||||
class="text-red-500 text-xs mt-1"
|
||||
>
|
||||
This chord is already taken
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.url"
|
||||
type="url"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Icon (UnoCSS class)
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.icon"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="i-lucide:globe (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Custom SVG Icon
|
||||
</label>
|
||||
<textarea
|
||||
v-model="formData.customSvg"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none h-20 resize-none"
|
||||
placeholder="Paste SVG code here (optional)"
|
||||
/>
|
||||
<p class="text-xs text-text-2 mt-1">
|
||||
If provided, this will override the icon class
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text-2 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<input
|
||||
v-model="formData.color"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-div rounded-md bg-bg-alt text-text focus:border-primary outline-none"
|
||||
placeholder="#3B82F6 (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!isFormValid()"
|
||||
class="flex-1 bg-primary text-white py-2 px-4 rounded-md font-medium hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<DialogClose as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 bg-bg-alt text-text py-2 px-4 rounded-md font-medium border border-div hover:bg-bg-elv transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<DialogRoot v-model:open="isDeleteDialogOpen">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 bg-black/50 z-50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-bg border border-div rounded-lg p-6 w-full max-w-md z-50"
|
||||
>
|
||||
<DialogTitle class="text-lg font-semibold text-text mb-2">
|
||||
Delete Bookmark
|
||||
</DialogTitle>
|
||||
<DialogDescription class="text-text-2 mb-6">
|
||||
Are you sure you want to delete "{{ deletingBookmark?.name }}"? This
|
||||
action cannot be undone.
|
||||
</DialogDescription>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="handleDeleteBookmark"
|
||||
class="flex-1 bg-red-600 text-white py-2 px-4 rounded-md font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<DialogClose as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 bg-bg-alt text-text py-2 px-4 rounded-md font-medium border border-div hover:bg-bg-elv transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
</template>
|
|
@ -1,29 +0,0 @@
|
|||
<template>
|
||||
<div class="text-6xl font-bold text-text">
|
||||
{{ timeString }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const time = ref<Date | null>(null)
|
||||
|
||||
function updateTime() {
|
||||
time.value = new Date()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
const interval = setInterval(updateTime, 1000)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const timeString = computed(() => {
|
||||
if (!time.value) return '--:--:--'
|
||||
const h = String(time.value.getHours()).padStart(2, '0')
|
||||
const m = String(time.value.getMinutes()).padStart(2, '0')
|
||||
const s = String(time.value.getSeconds()).padStart(2, '0')
|
||||
return `${h}:${m}:${s}`
|
||||
})
|
||||
</script>
|
|
@ -1,194 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import Clock from './Clock.vue'
|
||||
|
||||
export interface PlatformType {
|
||||
name: string
|
||||
key: string
|
||||
url: string
|
||||
icon?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
onFocusChange: (focused: boolean) => void
|
||||
initialQuery?: string
|
||||
}>()
|
||||
|
||||
const platforms: PlatformType[] = [
|
||||
{
|
||||
name: 'SearXNG',
|
||||
key: 'a',
|
||||
url: 'https://searx.fmhy.net/search?q=',
|
||||
icon: 'i-simple-icons:searxng'
|
||||
},
|
||||
{
|
||||
name: 'ChatGPT',
|
||||
key: 's',
|
||||
url: 'https://chat.openai.com/?q=',
|
||||
icon: 'i-simple-icons:openai'
|
||||
},
|
||||
{
|
||||
name: 'Claude',
|
||||
key: 'd',
|
||||
url: 'https://claude.ai/chat/',
|
||||
icon: 'i-logos:claude-icon'
|
||||
},
|
||||
{
|
||||
name: 'Perplexity',
|
||||
key: 'f',
|
||||
url: 'https://www.perplexity.ai/search?q=',
|
||||
icon: 'i-logos:perplexity-icon'
|
||||
}
|
||||
]
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const query = ref(props.initialQuery ?? '')
|
||||
const isInputFocused = ref(false)
|
||||
const showShortcuts = ref(false)
|
||||
|
||||
function handleInputFocus() {
|
||||
isInputFocused.value = true
|
||||
props.onFocusChange(true)
|
||||
}
|
||||
|
||||
function handleInputBlur() {
|
||||
isInputFocused.value = false
|
||||
props.onFocusChange(false)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!query.value.trim()) return
|
||||
const google = platforms.find((p) => p.name === 'SearX') || platforms[0]
|
||||
if (google)
|
||||
window.open(google.url + encodeURIComponent(query.value.trim()), '_self')
|
||||
}
|
||||
|
||||
function handlePlatformClick(platform: PlatformType) {
|
||||
if (!query.value.trim()) return
|
||||
window.open(platform.url + encodeURIComponent(query.value.trim()), '_self')
|
||||
}
|
||||
|
||||
function platformClass() {
|
||||
const base =
|
||||
'widget-card group relative widget-button rounded-md bg-bg-elv p-2 border transition-transform'
|
||||
const disabled = !query.value.trim()
|
||||
const highlight = showShortcuts.value && isInputFocused.value
|
||||
return [
|
||||
base,
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
highlight ? 'border-2 border-primary scale-105' : 'border-div'
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const active = document.activeElement
|
||||
const isSearchFocused = inputRef.value === active
|
||||
|
||||
if (e.key === '/' && !isSearchFocused) {
|
||||
const typingInInput =
|
||||
active &&
|
||||
(active.tagName === 'INPUT' ||
|
||||
active.tagName === 'TEXTAREA' ||
|
||||
(active instanceof HTMLElement && active.isContentEditable))
|
||||
if (!typingInInput) {
|
||||
e.preventDefault()
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isInputFocused.value && e.altKey) {
|
||||
const key = e.key.toLowerCase()
|
||||
let platform = platforms.find((p) => p.key === key)
|
||||
|
||||
if (!platform && e.code.startsWith('Key') && e.code.length === 4) {
|
||||
const codeKey = e.code.slice(3).toLowerCase()
|
||||
platform = platforms.find((p) => p.key === codeKey)
|
||||
}
|
||||
|
||||
if (platform && query.value.trim()) {
|
||||
e.preventDefault()
|
||||
window.open(
|
||||
platform.url + encodeURIComponent(query.value.trim()),
|
||||
'_self'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.altKey) showShortcuts.value = true
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (!e.altKey) showShortcuts.value = false
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start w-full space-y-4 antialiased">
|
||||
<Clock />
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="relative w-full">
|
||||
<div class="relative">
|
||||
<i
|
||||
class="i-lucide-search absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-text-2"
|
||||
/>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="query"
|
||||
@focus="handleInputFocus"
|
||||
@blur="handleInputBlur"
|
||||
placeholder="What would you like to search for?"
|
||||
class="w-full pl-10 pr-3 py-3 text-lg rounded-md shadow-sm transition-colors bg-bg-elv text-text border-2 outline-none border-div hover:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 w-full">
|
||||
<button
|
||||
v-for="platform in platforms"
|
||||
:key="platform.name"
|
||||
:disabled="!query.trim()"
|
||||
@click="handlePlatformClick(platform)"
|
||||
:class="platformClass()"
|
||||
:style="platform.color ? { borderColor: platform.color } : {}"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
v-if="platform.icon"
|
||||
:class="`w-5 h-5 ${platform.icon}`"
|
||||
:style="platform.color ? { color: platform.color } : {}"
|
||||
/>
|
||||
<div class="text-left flex-grow">
|
||||
<h3 class="font-semibold truncate">{{ platform.name }}</h3>
|
||||
</div>
|
||||
<div
|
||||
class="hidden sm:flex items-center gap-1 text-xs ml-auto text-white"
|
||||
>
|
||||
<kbd
|
||||
class="bg-bg border border-div px-1 py-0.5 rounded text-sm font-semibold"
|
||||
>
|
||||
Alt
|
||||
</kbd>
|
||||
<span class="text-white font-semibold">+</span>
|
||||
<kbd
|
||||
class="bg-bg border border-div px-1 py-0.5 rounded text-sm font-semibold"
|
||||
>
|
||||
{{ platform.key.toUpperCase() }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,49 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import Bookmarks from './Bookmarks.vue'
|
||||
import Searchbar from './SearchBar.vue'
|
||||
|
||||
const isFocused = ref(false)
|
||||
const handleFocusChange = (focused: boolean) => {
|
||||
isFocused.value = focused
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex pt-48 justify-center p-4 transition-colors">
|
||||
<div class="w-full max-w-7xl space-y-8">
|
||||
<Searchbar @focus-change="handleFocusChange" />
|
||||
<Bookmarks :is-input-globally-focused="isFocused" />
|
||||
|
||||
<div class="hidden sm:block space-y-2 text-sm text-text-2">
|
||||
<p>
|
||||
Press
|
||||
<kbd class="kbd">/</kbd>
|
||||
anywhere to focus the search box
|
||||
</p>
|
||||
<p>
|
||||
Use
|
||||
<kbd class="kbd">Alt + a/s/d/f</kbd>
|
||||
to search different platforms
|
||||
</p>
|
||||
<p>
|
||||
Type bookmark chords (like
|
||||
<kbd class="kbd px-1.5 py-0.5">H</kbd>
|
||||
<kbd class="kbd px-1.5 py-0.5">N</kbd>
|
||||
for Hacker News) when search is not focused
|
||||
</p>
|
||||
<p>
|
||||
Press
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
to search SearXNG (hosted by us) by default
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
kbd {
|
||||
--uno: px-1.5 py-0.5 rounded-sm font-sans font-semibold text-xs bg-bg-alt
|
||||
text-text-2 border-2 border-div nowrap;
|
||||
}
|
||||
</style>
|
|
@ -1,42 +0,0 @@
|
|||
import type { NProgress } from 'nprogress'
|
||||
import type { EnhanceAppContext } from 'vitepress'
|
||||
import nprogress from 'nprogress'
|
||||
|
||||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function loadProgress(
|
||||
router: EnhanceAppContext['router']
|
||||
): NProgress | undefined {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
setTimeout(() => {
|
||||
nprogress.configure({ showSpinner: false })
|
||||
|
||||
const cacheBeforeRouteChange = router.onBeforeRouteChange
|
||||
const cacheAfterRouteChange = router.onAfterRouteChanged
|
||||
router.onBeforeRouteChange = (to) => {
|
||||
nprogress.start()
|
||||
cacheBeforeRouteChange?.(to)
|
||||
}
|
||||
router.onAfterRouteChanged = (to) => {
|
||||
nprogress.done()
|
||||
cacheAfterRouteChange?.(to)
|
||||
}
|
||||
})
|
||||
|
||||
return nprogress
|
||||
}
|
|
@ -1,36 +1,3 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import Theme from '../../../website/theme/index'
|
||||
|
||||
import type { Theme } from 'vitepress'
|
||||
import Components from '@fmhy/components'
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import Layout from './Layout.vue'
|
||||
import Post from './PostLayout.vue'
|
||||
|
||||
import './style.scss'
|
||||
import 'virtual:uno.css'
|
||||
|
||||
import Feedback from './components/Feedback.vue'
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout,
|
||||
enhanceApp({ router, app }) {
|
||||
app.use(Components)
|
||||
app.component('Post', Post)
|
||||
app.component('Feedback', Feedback)
|
||||
}
|
||||
} satisfies Theme
|
||||
export default Theme
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ContentData } from 'vitepress'
|
||||
import { createContentLoader } from 'vitepress'
|
||||
import { groupBy } from '../utils'
|
||||
|
||||
interface Post {
|
||||
title: string
|
||||
url: string
|
||||
date: string
|
||||
}
|
||||
|
||||
type Dictionary = ReturnType<typeof createContentLoader>
|
||||
|
||||
declare const data: Dictionary
|
||||
export { data }
|
||||
|
||||
function transformRawPosts(rawPosts: ContentData[]): Record<string, Post[]> {
|
||||
const posts: Post[] = rawPosts
|
||||
.map(({ url, frontmatter }) => ({
|
||||
title: frontmatter.title,
|
||||
url,
|
||||
date: (frontmatter.date as Date).toISOString().slice(0, 10)
|
||||
}))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
|
||||
return groupBy(posts, (post) => post.date.slice(0, 4))
|
||||
}
|
||||
|
||||
export default createContentLoader('posts/*.md', {
|
||||
includeSrc: true,
|
||||
transform: (raw) => transformRawPosts(raw)
|
||||
})
|
|
@ -1,304 +0,0 @@
|
|||
:root {
|
||||
--vp-c-bg: #fefbff;
|
||||
--vp-c-bg-alt: #f2f0f4;
|
||||
--vp-c-bg-elv: #f2f0f4;
|
||||
--vp-c-bg-soft: #f2f0f4;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-bg: #121316;
|
||||
--vp-c-bg-alt: #0d0e11;
|
||||
--vp-c-bg-elv: #1b1b1f;
|
||||
--vp-c-bg-soft: #1b1b1f;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-c-border: #c2c2c4;
|
||||
--vp-c-divider: #c4c6d0;
|
||||
--vp-c-gutter: #e2e2e3;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-border: #77777a;
|
||||
--vp-c-divider: #222429;
|
||||
--vp-c-gutter: #000000;
|
||||
}
|
||||
|
||||
:root {
|
||||
--vp-c-text-1: #1b1b1f;
|
||||
--vp-c-text-2: #2f3033;
|
||||
--vp-c-text-3: #46464a;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--vp-c-text-1: #e3e2e6;
|
||||
--vp-c-text-2: #ababaf;
|
||||
--vp-c-text-3: #919094;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Colors: Brand */
|
||||
--vp-c-brand-1: theme('colors.swarm.500');
|
||||
--vp-c-brand-2: theme('colors.swarm.600');
|
||||
--vp-c-brand-3: theme('colors.swarm.800');
|
||||
--vp-c-brand-soft: theme('colors.swarm.400');
|
||||
|
||||
/* Colors: Button */
|
||||
--vp-button-brand-bg: var(--vp-c-brand-1);
|
||||
--vp-button-brand-border: var(--vp-c-brand-soft);
|
||||
--vp-button-brand-text: rgba(42, 40, 47);
|
||||
--vp-button-brand-hover-border: var(--vp-c-brand-soft);
|
||||
--vp-button-brand-hover-text: rgba(42, 40, 47);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-soft);
|
||||
--vp-button-brand-active-border: var(--vp-c-brand-soft);
|
||||
--vp-button-brand-active-text: rgba(42, 40, 47);
|
||||
--vp-button-brand-active-bg: var(--vp-button-brand-bg);
|
||||
--vp-button-alt-bg: #484848;
|
||||
--vp-button-alt-text: #f0eeee;
|
||||
--vp-button-alt-hover-bg: #484848;
|
||||
--vp-button-alt-hover-text: #f0eeee;
|
||||
|
||||
/* Colors: Custom Block */
|
||||
/** Info */
|
||||
--vp-custom-block-info-bg: theme('colors.swarm.100');
|
||||
--vp-custom-block-info-border: theme('colors.swarm.800');
|
||||
--vp-custom-block-info-text: theme('colors.swarm.800');
|
||||
--vp-custom-block-info-text-deep: theme('colors.swarm.900');
|
||||
/** Tip */
|
||||
--vp-custom-block-tip-bg: theme('colors.meadow.100');
|
||||
--vp-custom-block-tip-border: theme('colors.meadow.800');
|
||||
--vp-custom-block-tip-text: theme('colors.meadow.800');
|
||||
--vp-custom-block-tip-text-deep: theme('colors.meadow.900');
|
||||
/** Warning */
|
||||
--vp-custom-block-warning-bg: theme('colors.merlin.100');
|
||||
--vp-custom-block-warning-border: theme('colors.merlin.800');
|
||||
--vp-custom-block-warning-text: theme('colors.merlin.800');
|
||||
--vp-custom-block-warning-text-deep: theme('colors.merlin.900');
|
||||
/** Danger */
|
||||
--vp-custom-block-danger-bg: theme('colors.carnation.100');
|
||||
--vp-custom-block-danger-border: theme('colors.carnation.800');
|
||||
--vp-custom-block-danger-text: theme('colors.carnation.800');
|
||||
--vp-custom-block-danger-text-deep: theme('colors.carnation.900');
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Colors: Brand */
|
||||
--vp-c-brand-1: theme('colors.swarm.400');
|
||||
--vp-c-brand-2: theme('colors.swarm.500');
|
||||
--vp-c-brand-3: theme('colors.swarm.700');
|
||||
--vp-c-brand-soft: theme('colors.swarm.300');
|
||||
|
||||
/* Colors: Custom Block */
|
||||
/** Info */
|
||||
--vp-custom-block-info-bg: theme('colors.swarm.950');
|
||||
--vp-custom-block-info-border: theme('colors.swarm.800');
|
||||
--vp-custom-block-info-text: theme('colors.swarm.200');
|
||||
--vp-custom-block-info-text-deep: theme('colors.swarm.200');
|
||||
/** Tip */
|
||||
--vp-custom-block-tip-bg: theme('colors.meadow.950');
|
||||
--vp-custom-block-tip-border: theme('colors.meadow.800');
|
||||
--vp-custom-block-tip-text: theme('colors.meadow.200');
|
||||
--vp-custom-block-tip-text-deep: theme('colors.meadow.200');
|
||||
/** Warning */
|
||||
--vp-custom-block-warning-bg: theme('colors.merlin.950');
|
||||
--vp-custom-block-warning-border: theme('colors.merlin.800');
|
||||
--vp-custom-block-warning-text: theme('colors.merlin.200');
|
||||
--vp-custom-block-warning-text-deep: theme('colors.merlin.200');
|
||||
/** Danger */
|
||||
--vp-custom-block-danger-bg: theme('colors.carnation.950');
|
||||
--vp-custom-block-danger-border: theme('colors.carnation.800');
|
||||
--vp-custom-block-danger-text: theme('colors.carnation.200');
|
||||
--vp-custom-block-danger-text-deep: theme('colors.carnation.200');
|
||||
}
|
||||
|
||||
.vp-doc a {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-style: solid;
|
||||
text-decoration-color: transparent;
|
||||
-webkit-text-decoration-color: transparent;
|
||||
transition: text-decoration-color 0.25s;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-brand-1);
|
||||
text-decoration-color: var(--vp-c-brand-1);
|
||||
-webkit-text-decoration-color: var(--vp-c-brand-1);
|
||||
}
|
||||
}
|
||||
|
||||
.vp-doc .custom-block a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
|
||||
::selection {
|
||||
background-color: var(--vp-c-brand-400, #5586a6);
|
||||
|
||||
.dark & {
|
||||
background-color: var(--vp-c-brand-800, #0f2c47);
|
||||
}
|
||||
}
|
||||
|
||||
.VPFooter a {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dashed;
|
||||
text-underline-offset: 5px;
|
||||
transition: 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: var(--vp-c-text-1);
|
||||
text-decoration-line: underline;
|
||||
text-decoration-style: dashed;
|
||||
text-underline-offset: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.VPSidebar::-webkit-scrollbar {
|
||||
block-size: 4px;
|
||||
border-end-end-radius: 14px;
|
||||
border-start-end-radius: 14px;
|
||||
inline-size: 4px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Home
|
||||
*/
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(
|
||||
120deg,
|
||||
#c4b5fd 30%,
|
||||
#7bc5e4
|
||||
);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(
|
||||
-45deg,
|
||||
#c4b5fd 50%,
|
||||
#47caff 50%
|
||||
);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#VPContent strong > a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.info.custom-block a {
|
||||
color: var(--vp-custom-block-info-text);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.info.custom-block a:hover {
|
||||
opacity: 0.7;
|
||||
color: var(--vp-custom-block-info-text-deep);
|
||||
}
|
||||
|
||||
.note.custom-block a {
|
||||
color: var(--vp-custom-block-info-text);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.note.custom-block a:hover {
|
||||
opacity: 0.7;
|
||||
color: var(--vp-custom-block-note-text-deep);
|
||||
}
|
||||
|
||||
.tip.custom-block a {
|
||||
color: var(--vp-custom-block-tip-text);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.tip.custom-block a:hover {
|
||||
opacity: 0.7;
|
||||
color: var(--vp-custom-block-tip-text-deep);
|
||||
}
|
||||
|
||||
.warning.custom-block a {
|
||||
color: var(--vp-custom-block-warning-text);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.warning.custom-block a:hover {
|
||||
opacity: 0.7;
|
||||
color: var(--vp-custom-block-warning-text-deep);
|
||||
}
|
||||
|
||||
.danger.custom-block a {
|
||||
color: var(--vp-custom-block-danger-text);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: opacity 0.25s;
|
||||
}
|
||||
|
||||
.danger.custom-block a:hover {
|
||||
opacity: 0.7;
|
||||
color: var(--vp-custom-block-danger-text-deep);
|
||||
}
|
||||
|
||||
.info.custom-block {
|
||||
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWluZm8iPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTEyIDE2di00Ii8+PHBhdGggZD0iTTEyIDhoLjAxIi8+PC9zdmc+');
|
||||
}
|
||||
|
||||
.note.custom-block {
|
||||
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWluZm8iPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTEyIDE2di00Ii8+PHBhdGggZD0iTTEyIDhoLjAxIi8+PC9zdmc+');
|
||||
}
|
||||
|
||||
.tip.custom-block {
|
||||
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpZ2h0YnVsYiI+PHBhdGggZD0iTTE1IDE0Yy4yLTEgLjctMS43IDEuNS0yLjUgMS0uOSAxLjUtMi4yIDEuNS0zLjVBNiA2IDAgMCAwIDYgOGMwIDEgLjIgMi4yIDEuNSAzLjUuNy43IDEuMyAxLjUgMS41IDIuNSIvPjxwYXRoIGQ9Ik05IDE4aDYiLz48cGF0aCBkPSJNMTAgMjJoNCIvPjwvc3ZnPg==');
|
||||
}
|
||||
|
||||
.warning.custom-block {
|
||||
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWFsZXJ0LXRyaWFuZ2xlIj48cGF0aCBkPSJtMjEuNzMgMTgtOC0xNGEyIDIgMCAwIDAtMy40OCAwbC04IDE0QTIgMiAwIDAgMCA0IDIxaDE2YTIgMiAwIDAgMCAxLjczLTNaIi8+PHBhdGggZD0iTTEyIDl2NCIvPjxwYXRoIGQ9Ik0xMiAxN2guMDEiLz48L3N2Zz4=');
|
||||
}
|
||||
|
||||
.danger.custom-block {
|
||||
--icon: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNrdWxsIj48Y2lyY2xlIGN4PSI5IiBjeT0iMTIiIHI9IjEiLz48Y2lyY2xlIGN4PSIxNSIgY3k9IjEyIiByPSIxIi8+PHBhdGggZD0iTTggMjB2Mmg4di0yIi8+PHBhdGggZD0ibTEyLjUgMTctLjUtMS0uNSAxaDF6Ii8+PHBhdGggZD0iTTE2IDIwYTIgMiAwIDAgMCAxLjU2LTMuMjUgOCA4IDAgMSAwLTExLjEyIDBBMiAyIDAgMCAwIDggMjAiLz48L3N2Zz4=');
|
||||
}
|
||||
|
||||
.custom-block-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-block-title::before {
|
||||
content: '';
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
-webkit-mask: var(--icon) no-repeat;
|
||||
mask: var(--icon) no-repeat;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
background-color: currentColor;
|
||||
color: inherit;
|
||||
}
|
|
@ -1,349 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Plugin } from 'vitepress'
|
||||
import { basename } from 'pathe'
|
||||
import { excluded, getHeader } from './transformer/constants'
|
||||
import { replaceUnderscore, transformer } from './transformer/core'
|
||||
|
||||
export function transformsPlugin(): Plugin {
|
||||
return {
|
||||
name: 'custom:transform-content',
|
||||
enforce: 'pre',
|
||||
transform(code, id) {
|
||||
const _id = basename(id)
|
||||
|
||||
if (
|
||||
id.endsWith('.md') &&
|
||||
!excluded.includes(_id) &&
|
||||
// check if it's a post
|
||||
!id.includes('posts') &&
|
||||
!id.includes('other')
|
||||
) {
|
||||
const header = getHeader(_id)
|
||||
const contents = transform(code)
|
||||
|
||||
if (_id === 'beginners-guide.md') {
|
||||
const _contents = transformGuide(contents)
|
||||
return header + _contents
|
||||
}
|
||||
if (_id === 'storage.md') return header + contents
|
||||
|
||||
return header + transformLinks(contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const transformGuide = (text: string): string =>
|
||||
transformer(text)
|
||||
.transform('Beginners Guide', [
|
||||
{
|
||||
name: 'TOC',
|
||||
find: /\[TOC\]\n/gm,
|
||||
replace: ''
|
||||
},
|
||||
{
|
||||
name: 'TOC2',
|
||||
find: /\*\*Table of Contents\*\*\n\[TOC2\]\n/gm,
|
||||
replace: ''
|
||||
},
|
||||
{
|
||||
name: 'Beginners Guide',
|
||||
find: /# -> \*\*\*Beginners Guide to Piracy\*\*\* <-\n/gm,
|
||||
replace: ''
|
||||
},
|
||||
{
|
||||
name: 'Note',
|
||||
find: /!!!note\s(.+?)\n/gm,
|
||||
replace: '\n:::info\n$1\n:::\n'
|
||||
},
|
||||
{
|
||||
name: 'Info',
|
||||
find: /!!!info\s(.+?)\n/gm,
|
||||
replace: '\n:::info\n$1\n:::\n'
|
||||
},
|
||||
{
|
||||
name: 'Warning',
|
||||
find: /!!!warning\s(.+?)\n/gm,
|
||||
replace: ':::warning\n$1\n:::\n'
|
||||
},
|
||||
{
|
||||
name: 'Quote',
|
||||
find: />\s(.+?)\n/gm,
|
||||
replace: '> $1\n\n'
|
||||
},
|
||||
{
|
||||
name: 'Back to Top',
|
||||
find: /\*\*\[\^ Back to Top\]\(#beginners-guide-to-piracy\)\*\*/gm,
|
||||
replace: ''
|
||||
},
|
||||
{
|
||||
name: 'Back to Top',
|
||||
find: /\*\*\[\^ Back to Top\]\(#beginners-guide-to-piracy\)\*\*/gm,
|
||||
replace: ''
|
||||
}
|
||||
])
|
||||
.getText()
|
||||
|
||||
export function transform(text: string): string {
|
||||
let _text = text
|
||||
// Transform reddit index links
|
||||
.replace(
|
||||
/\*\*\[◄◄ Back to Wiki Index\]\(https:\/\/www\.reddit\.com\/r\/FREEMEDIAHECKYEAH\/wiki\/index\)\*\*\n/gm,
|
||||
''
|
||||
)
|
||||
.replace(
|
||||
/\*\*\[◄◄ Back to Wiki Index\]\(https:\/\/www\.reddit\.com\/r\/FREEMEDIAHECKYEAH\/wiki\/tools-index\)\*\*\n/gm,
|
||||
''
|
||||
)
|
||||
.replace(
|
||||
/\*\*\[Table of Contents\]\(https?:\/\/.*?ibb\.co.*\)\*\* - For mobile users\n/gm,
|
||||
''
|
||||
)
|
||||
// Remove extra lines
|
||||
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\*\*\*\n\n/gm, '')
|
||||
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\*\*\* \n\n/gm, '')
|
||||
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\*\*\*\n\n/gm, '')
|
||||
.replace(/\*\*\*\n\*\*\*\n\*\*\*\n\*\*\*\n\n\n\*\*\*\n\n/gm, '')
|
||||
.replace(/\*\*\*\n\*\*\*\n\n\n\*\*\*\n\n/gm, '')
|
||||
// Transform reddit links
|
||||
.replace(/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/ai/g, '/ai')
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/adblock-vpn-privacy/g,
|
||||
'/adblockvpnguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/android/g,
|
||||
'/android-iosguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/games/g,
|
||||
'/gamingpiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/reading/g,
|
||||
'/readingpiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/download/g,
|
||||
'/downloadpiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/torrent/g,
|
||||
'/torrentpiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/edu/g,
|
||||
'/edupiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/system-tools/g,
|
||||
'/system-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/file-tools/g,
|
||||
'/file-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/internet-tools/g,
|
||||
'/internet-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/social-media/g,
|
||||
'/social-media-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/text-tools/g,
|
||||
'/text-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/video-tools/g,
|
||||
'/video-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/audio-tools/g,
|
||||
'/audio-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/game-tools/g,
|
||||
'/gaming-tools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/video/g,
|
||||
'/videopiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/audio/g,
|
||||
'/audiopiracyguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/linux/g,
|
||||
'/linuxguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/non-eng/g,
|
||||
'/non-english'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/misc/g,
|
||||
'/miscguide'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/storage/g,
|
||||
'/storage'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/dev-tools/g,
|
||||
'/devtools'
|
||||
)
|
||||
.replace(
|
||||
/https:\/\/www.reddit.com\/r\/FREEMEDIAHECKYEAH\/wiki\/img-tools/g,
|
||||
'/img-tools'
|
||||
)
|
||||
// Remove extra characters
|
||||
.replace(/\/#wiki_/g, '/#')
|
||||
.replace(/#wiki_/g, '/#')
|
||||
.replace(/.25BA_/g, '')
|
||||
.replace(/.25B7_/g, '')
|
||||
.replace(/_.2F_/g, '-')
|
||||
|
||||
_text = replaceUnderscore(_text)
|
||||
.replace(/\/#(\d)/g, '/#_$1') // Prefix headings starting with numbers
|
||||
.replace(/#(\d)/g, '#_$1') // Prefix headings starting with numbers
|
||||
.replace(/(\]\(\s*)\/\s*(\#[^)\s]*?\s*\))/g, '$1$2')
|
||||
.replace(/\*\*\*\n\n/gm, '')
|
||||
.replace(/\*\*\*\n/gm, '')
|
||||
.replace(/# ►/g, '##')
|
||||
.replace(/## ▷/g, '###')
|
||||
.replace(/####/g, '###')
|
||||
// Replace emojis
|
||||
.replace(/🌟/g, ':glowing-star:')
|
||||
.replace(/⭐/g, ':star:')
|
||||
.replace(/🌐/g, ':globe-with-meridians:')
|
||||
.replace(/↪/g, ':repeat-button:')
|
||||
// Replace note/warning/tip
|
||||
.replace(/^\*\*Note\*\* - (.+)$/gm, ':::tip\n$1\n:::')
|
||||
.replace(/^\* \*\*Note\*\* - (.+)$/gm, ':::tip\n$1\n:::')
|
||||
.replace(/^Note - (.+)$/gm, ':::tip\n$1\n:::')
|
||||
.replace(/^\*\*Warning\*\* - (.+)$/gm, ':::warning\n$1\n:::')
|
||||
.replace(/^\* \*\*Warning\*\* - (.+)$/gm, ':::warning\n$1\n:::')
|
||||
.replace(/^\*\s([^*])/gm, '- $1')
|
||||
// Replace links
|
||||
.replace(
|
||||
/\/storage\/#encode--decode_urls/g,
|
||||
'/storage/#encode--decode-urls'
|
||||
)
|
||||
.replace(/\/devtools\/#machine-learning2/g, '/devtools/#machine-learning-1')
|
||||
.replace(/\/linuxguide#software-sites2/g, '/linuxguide#software-sites-1')
|
||||
.replace(/\/linuxguide#software_sites/g, '/linuxguide#software-sites')
|
||||
.replace(/\/non-english#reading10/g, '/non-english#reading-9')
|
||||
.replace(
|
||||
/\/storage#satellite-.26amp.3B_street_view_maps/g,
|
||||
'/storage#satellite-street-view-maps'
|
||||
)
|
||||
.replace(
|
||||
/(.+?) site or extension\.\n/gm,
|
||||
'Click on the texts to copy them decoded.\n'
|
||||
)
|
||||
|
||||
return _text
|
||||
}
|
||||
|
||||
const transformLinks = (text: string): string =>
|
||||
transformer(text)
|
||||
.transform('Links to Icons', [
|
||||
{
|
||||
name: 'Discord',
|
||||
find: /\[Discord\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="Discord" class="i-carbon:logo-discord" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'GitHub',
|
||||
find: /\[GitHub\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="GitHub" class="i-carbon:logo-github" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'GitLab',
|
||||
find: /\[GitLab\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="GitLab" class="i-carbon:logo-gitlab" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'Source Code',
|
||||
find: /\[Source Code\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="Source Code" class="i-gravity-ui:code" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'Telegram',
|
||||
find: /\[Telegram\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="Telegram" class="i-mdi:telegram" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'Subreddit',
|
||||
find: /\[Subreddit\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="Reddit" class="i-mdi:reddit" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'X',
|
||||
find: /\[X\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt="X" class="i-carbon:logo-x" /></a>'
|
||||
},
|
||||
{
|
||||
name: 'Tor',
|
||||
find: /\[.onion\]\(([^\)]*?)\)/gm,
|
||||
replace:
|
||||
'<a target="_blank" href="$1"><div alt=".onion" class="i-simple-icons:torbrowser w-1em h-1em" /></a>'
|
||||
},
|
||||
// Platform indicators
|
||||
{
|
||||
name: 'Windows',
|
||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Windows(?=,|[ \t]\/|$)/gm,
|
||||
replace: ' <div alt="Windows" class="i-qlementine-icons:windows-24" /> '
|
||||
},
|
||||
{
|
||||
name: 'Mac',
|
||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Mac(?=,|[ \t]\/|$)/gm,
|
||||
replace: ' <div alt="Mac" class="i-qlementine-icons:mac-fill-16" /> '
|
||||
},
|
||||
{
|
||||
name: 'Linux',
|
||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Linux(?=,|[ \t]\/|$)/gm,
|
||||
replace: ' <div alt="Linux" class="i-fluent-mdl2:linux-logo-32" /> '
|
||||
},
|
||||
{
|
||||
name: 'Android',
|
||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Android(?=,|[ \t]\/|$)/gm,
|
||||
replace: ' <div alt="Android" class="i-material-symbols:android" /> '
|
||||
},
|
||||
{
|
||||
name: 'iOS',
|
||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)iOS(?=,|[ \t]\/|$)/gm,
|
||||
replace: ' <div alt="iOS" class="i-simple-icons:ios" /> '
|
||||
},
|
||||
{
|
||||
name: 'Web',
|
||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Web(?=,|[ \t]\/|$)/gm,
|
||||
replace: ' <div alt="Web" class="i-fluent:globe-32-filled" /> '
|
||||
}
|
||||
])
|
||||
.getText()
|
|
@ -1,162 +0,0 @@
|
|||
import { meta } from '../constants'
|
||||
|
||||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
interface Header {
|
||||
[file: string]: { title: string; description: string }
|
||||
}
|
||||
|
||||
export const headers: Header = {
|
||||
'adblockvpnguide.md': {
|
||||
title: 'Adblocking / Privacy',
|
||||
description: 'Adblocking, Privacy, VPNs, Proxies, Antiviruses'
|
||||
},
|
||||
'ai.md': {
|
||||
title: 'Artificial Intelligence',
|
||||
description: 'Chatbots, Text Generators, Image Generators, ChatGPT Tools'
|
||||
},
|
||||
'android-iosguide.md': {
|
||||
title: 'Android / iOS',
|
||||
description: 'Apps, Jailbreaking, Android Emulators'
|
||||
},
|
||||
'audiopiracyguide.md': {
|
||||
title: 'Music / Podcasts / Radio',
|
||||
description: 'Stream Audio, Download Audio, Torrent Audio'
|
||||
},
|
||||
'beginners-guide.md': {
|
||||
title: 'Beginners Guide',
|
||||
description: 'A Guide for Beginners to Piracy'
|
||||
},
|
||||
'downloadpiracyguide.md': {
|
||||
title: 'Downloading',
|
||||
description: 'Download Sites, Software Sites, Open Directories'
|
||||
},
|
||||
'edupiracyguide.md': {
|
||||
title: 'Educational',
|
||||
description: 'Courses, Documentaries, Learning Resources'
|
||||
},
|
||||
'gamingpiracyguide.md': {
|
||||
title: 'Gaming / Emulation',
|
||||
description: 'Download Games, ROMs, Gaming Tools'
|
||||
},
|
||||
'linuxguide.md': {
|
||||
title: 'Linux / macOS',
|
||||
description: 'Apps, Software Sites, Gaming'
|
||||
},
|
||||
'miscguide.md': {
|
||||
title: 'Miscellaneous',
|
||||
description: 'Extensions, Indexes, News, Health, Food, Fun'
|
||||
},
|
||||
'nsfwpiracy.md': {
|
||||
title: 'NSFW',
|
||||
description: 'NSFW Indexes, Streaming, Downloading'
|
||||
},
|
||||
'non-english.md': {
|
||||
title: 'Non-English',
|
||||
description: 'International Piracy Sites'
|
||||
},
|
||||
'readingpiracyguide.md': {
|
||||
title: 'Books / Comics / Manga',
|
||||
description: 'Books, Comics, Magazines, Newspapers'
|
||||
},
|
||||
'gaming-tools.md': {
|
||||
title: 'Gaming Tools',
|
||||
description: 'Gaming Optimization, Game Launchers, Multiplayer'
|
||||
},
|
||||
'devtools.md': {
|
||||
title: 'Developer Tools',
|
||||
description: 'Git, Hosting, App Dev, Software Dev'
|
||||
},
|
||||
'img-tools.md': {
|
||||
title: 'Image Tools',
|
||||
description: 'Image Editors, Generators, Compress'
|
||||
},
|
||||
'audio-tools.md': {
|
||||
title: 'Audio Tools',
|
||||
description: 'Audio Players, Audio Editors, Audio Downloaders'
|
||||
},
|
||||
'system-tools.md': {
|
||||
title: 'System Tools',
|
||||
description: 'System Tools, Hardware Tools, Windows ISOs, Customization'
|
||||
},
|
||||
'file-tools.md': {
|
||||
title: 'File Tools',
|
||||
description: 'Download Managers, File Hosting, File Archivers'
|
||||
},
|
||||
'video-tools.md': {
|
||||
title: 'Video Tools',
|
||||
description: 'Video Players, Video Editors, Live Streaming, Animation'
|
||||
},
|
||||
'text-tools.md': {
|
||||
title: 'Text Tools',
|
||||
description: 'Text Editors, Pastebins, Fonts, Translators'
|
||||
},
|
||||
'internet-tools.md': {
|
||||
title: 'Internet Tools',
|
||||
description: 'Browsers, Extensions, Search Engines'
|
||||
},
|
||||
'social-media-tools.md': {
|
||||
title: 'Social Media Tools',
|
||||
description: 'Discord Tools, Reddit Tools, YouTube Tools'
|
||||
},
|
||||
'storage.md': {
|
||||
title: 'Storage',
|
||||
description: 'Sections too big to fit on main pages'
|
||||
},
|
||||
'torrentpiracyguide.md': {
|
||||
title: 'Torrenting',
|
||||
description: 'Torrent Clients, Torrent Sites, Trackers'
|
||||
},
|
||||
'videopiracyguide.md': {
|
||||
title: 'Movies / TV / Anime',
|
||||
description: 'Stream Videos, Download Videos, Torrent Videos'
|
||||
},
|
||||
'base64.md': {
|
||||
title: 'Base64',
|
||||
description: 'Base64 storage'
|
||||
},
|
||||
'unsafesites.md': {
|
||||
title: 'Unsafe Sites',
|
||||
description: 'Unsafe/harmful sites to avoid.'
|
||||
}
|
||||
} as const
|
||||
|
||||
export const excluded = [
|
||||
'readme.md',
|
||||
'single-page',
|
||||
'feedback.md',
|
||||
'index.md',
|
||||
'sandbox.md',
|
||||
'startpage.md'
|
||||
]
|
||||
|
||||
export function getHeader(id: string) {
|
||||
const title =
|
||||
'<div class="space-y-2 not-prose"><h1 class="text-4xl font-extrabold tracking-tight text-primary underline lg:text-5xl lg:leading-[3.5rem]">'
|
||||
|
||||
const description = '<p class="text-black dark:text-text-2">'
|
||||
|
||||
const feedback = meta.build.api ? '<Feedback />' : ''
|
||||
|
||||
const data = headers[id]
|
||||
let header = '---\n'
|
||||
header += `title: "${data.title}"\n`
|
||||
header += `description: ${data.description}\n`
|
||||
header += '---\n'
|
||||
header += `${title}${data.title}</h1>\n`
|
||||
header += `${description}${data.description}</p></div>\n\n${feedback}\n\n`
|
||||
return header
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import consola from 'consola'
|
||||
|
||||
type Transform = {
|
||||
name: string
|
||||
find: string | RegExp
|
||||
replace: string | ((match: string) => string)
|
||||
}
|
||||
|
||||
type TransformerFunc = (name: string, transforms: Transform[]) => Replacer
|
||||
|
||||
interface Replacer {
|
||||
transform: TransformerFunc
|
||||
getText(): string
|
||||
}
|
||||
|
||||
export const transformer = (text: string) => {
|
||||
const handler: ProxyHandler<{ text: string }> = {
|
||||
get(target: { text: string }, prop: string | symbol) {
|
||||
if (prop === 'transform') {
|
||||
return (name: string, transforms: Transform[]): Replacer => {
|
||||
consola.debug(`Starting transform ${name} with ${transforms}`)
|
||||
|
||||
transforms.forEach(({ name, find, replace }) => {
|
||||
consola.debug(`Transforming ${name} with ${find}`)
|
||||
target.text = target.text.replace(find, replace as any)
|
||||
})
|
||||
|
||||
// @ts-expect-error - Proxy is not typed
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
if (prop === 'getText') {
|
||||
return () => target.text
|
||||
}
|
||||
return Reflect.get(target, prop)
|
||||
}
|
||||
}
|
||||
|
||||
const target = { text }
|
||||
const proxy = new Proxy(target, handler)
|
||||
return proxy as unknown as Replacer
|
||||
}
|
||||
|
||||
export function replaceUnderscore(text: string): string {
|
||||
const pattern = /\/#[\w\-]+(?:_[\w]+)*/g
|
||||
const matches = text.match(pattern) || []
|
||||
let _text = text
|
||||
for (const match of matches) {
|
||||
const replacement = match.replace(/_/g, '-')
|
||||
_text = _text.replace(match, replacement)
|
||||
}
|
||||
return _text
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import z from 'zod'
|
||||
|
||||
export const FeedbackSchema = z.object({
|
||||
message: z.string().min(5).max(1000),
|
||||
type: z.enum(['suggestion', 'appreciation', 'other']),
|
||||
page: z.string().min(3).max(20),
|
||||
// For heading based feedback
|
||||
heading: z.string().min(3).max(20).optional()
|
||||
})
|
||||
|
||||
export interface Option {
|
||||
label: string
|
||||
value: FeedbackType['type']
|
||||
}
|
||||
|
||||
export const feedbackOptions: Option[] = [
|
||||
{
|
||||
label: '💡 I have a suggestion',
|
||||
value: 'suggestion'
|
||||
},
|
||||
{
|
||||
label: '👍 I appreciate the work',
|
||||
value: 'appreciation'
|
||||
},
|
||||
{ label: '📂 Something else', value: 'other' }
|
||||
]
|
||||
|
||||
export function getFeedbackOption(
|
||||
value: FeedbackType['type']
|
||||
): Option | undefined {
|
||||
return feedbackOptions.find((option) => option.value === value)
|
||||
}
|
||||
|
||||
export type FeedbackType = z.infer<typeof FeedbackSchema>
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export function groupBy<T, K extends keyof any>(
|
||||
arr: T[],
|
||||
key: (i: T) => K
|
||||
): Record<K, T[]> {
|
||||
return arr.reduce(
|
||||
(groups, item) => {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
|
||||
;(groups[key(item)] ||= []).push(item)
|
||||
return groups
|
||||
},
|
||||
{} as Record<K, T[]>
|
||||
)
|
||||
}
|
22
docs/.vitepress/vue-shim.d.ts
vendored
22
docs/.vitepress/vue-shim.d.ts
vendored
|
@ -1,22 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) 2025 taskylizard. Apache License 2.0.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
/* eslint-disable ts/consistent-type-imports */
|
||||
declare module '*.vue' {
|
||||
const component: import('vue').Component
|
||||
export default component
|
||||
}
|
||||
|
||||
declare const FMHY_HOMEPAGE_ITEMS: typeof import('./constants').wikiSidebar
|
|
@ -6,7 +6,7 @@ prev: false
|
|||
---
|
||||
|
||||
<script setup>
|
||||
import WallpaperCard from '../.vitepress/theme/components/WallpaperCard.vue'
|
||||
import WallpaperCard from '../../website/theme/components/WallpaperCard.vue'
|
||||
</script>
|
||||
|
||||
# Wallpapers
|
||||
|
|
|
@ -9,7 +9,7 @@ sidebar: true
|
|||
---
|
||||
|
||||
<script setup>
|
||||
import Index from './.vitepress/theme/Posts.vue'
|
||||
import Index from '../website/theme/Posts.vue'
|
||||
</script>
|
||||
|
||||
<Index/>
|
||||
|
|
|
@ -5,7 +5,7 @@ pageClass: startpage-custom-styling
|
|||
---
|
||||
|
||||
<script setup>
|
||||
import StartPage from './.vitepress/theme/components/startpage/Startpage.vue'
|
||||
import StartPage from '../website/theme/components/startpage/Startpage.vue'
|
||||
</script>
|
||||
|
||||
<StartPage />
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue