m9ve website code outsidr

This commit is contained in:
taskylizard 2025-06-27 22:37:01 +00:00
parent 15a56af368
commit df4eecc405
No known key found for this signature in database
GPG key ID: 1820131ED1A24120
87 changed files with 3108 additions and 59 deletions

View file

@ -0,0 +1,13 @@
<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>

View file

@ -0,0 +1,49 @@
<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>

View file

@ -0,0 +1,16 @@
<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>

View file

@ -0,0 +1,114 @@
<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>

View file

@ -0,0 +1,363 @@
<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>

View file

@ -0,0 +1,47 @@
<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>

View file

@ -0,0 +1,30 @@
<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>

View file

@ -0,0 +1,52 @@
<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>

View file

@ -0,0 +1,16 @@
<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>

View file

@ -0,0 +1,346 @@
<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>

View file

@ -0,0 +1,37 @@
<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>

View file

@ -0,0 +1,733 @@
<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>

View file

@ -0,0 +1,29 @@
<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>

View file

@ -0,0 +1,194 @@
<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>

View file

@ -0,0 +1,49 @@
<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>