mirror of
https://github.com/fmhy/edit.git
synced 2025-07-29 23:32:17 +10:00
feat: startpage
This commit is contained in:
parent
6418da63ba
commit
8d6c3ace0b
19 changed files with 7804 additions and 6133 deletions
|
@ -10,6 +10,5 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- '4173:4173'
|
- '4173:4173'
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
fmhy:
|
fmhy:
|
||||||
|
|
|
@ -152,8 +152,10 @@ export default defineConfig({
|
||||||
search,
|
search,
|
||||||
footer: {
|
footer: {
|
||||||
message: `${feedback} (rev: ${commitRef})`,
|
message: `${feedback} (rev: ${commitRef})`,
|
||||||
copyright: `© ${new Date().getFullYear()}, <a href="https://i.ibb.co/pLVXBSh/image.png">Estd 2018.</a>` + `<br/> This site does not host any files.`
|
copyright:
|
||||||
},
|
`© ${new Date().getFullYear()}, <a href="https://i.ibb.co/pLVXBSh/image.png">Estd 2018.</a>` +
|
||||||
|
`<br/> This site does not host any files.`
|
||||||
|
},
|
||||||
editLink: {
|
editLink: {
|
||||||
pattern: 'https://github.com/fmhy/edit/edit/main/docs/:path',
|
pattern: 'https://github.com/fmhy/edit/edit/main/docs/:path',
|
||||||
text: '📝 Edit this page'
|
text: '📝 Edit this page'
|
||||||
|
|
|
@ -153,7 +153,10 @@ export const nav: DefaultTheme.NavItem[] = [
|
||||||
{ text: '🔖 Bookmarks', link: 'https://github.com/fmhy/bookmarks' },
|
{ text: '🔖 Bookmarks', link: 'https://github.com/fmhy/bookmarks' },
|
||||||
{ text: '✅ SafeGuard', link: 'https://github.com/fmhy/FMHY-SafeGuard' },
|
{ text: '✅ SafeGuard', link: 'https://github.com/fmhy/FMHY-SafeGuard' },
|
||||||
{ text: '📋 snowbin', link: 'https://pastes.fmhy.net' },
|
{ text: '📋 snowbin', link: 'https://pastes.fmhy.net' },
|
||||||
{ text: '®️ Redlib', link: 'https://redlib.fmhy.net/r/FREEMEDIAHECKYEAH/wiki/index' },
|
{
|
||||||
|
text: '®️ Redlib',
|
||||||
|
link: 'https://redlib.fmhy.net/r/FREEMEDIAHECKYEAH/wiki/index'
|
||||||
|
},
|
||||||
{ text: '🔎 SearXNG', link: 'https://searx.fmhy.net/' },
|
{ text: '🔎 SearXNG', link: 'https://searx.fmhy.net/' },
|
||||||
{
|
{
|
||||||
text: '💡 Site Hunting',
|
text: '💡 Site Hunting',
|
||||||
|
|
|
@ -266,7 +266,8 @@ const toggleCard = () => (isCardShown.value = !isCardShown.value)
|
||||||
placeholder="What a lovely wiki!"
|
placeholder="What a lovely wiki!"
|
||||||
/>
|
/>
|
||||||
<p class="desc mb-2">
|
<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
|
Add your Discord handle if you would like a response, or if we need
|
||||||
|
more information from you, otherwise join our
|
||||||
<a
|
<a
|
||||||
class="text-primary text-underline font-semibold"
|
class="text-primary text-underline font-semibold"
|
||||||
href="https://rentry.co/FMHY-Invite/"
|
href="https://rentry.co/FMHY-Invite/"
|
||||||
|
|
733
docs/.vitepress/theme/components/startpage/Bookmarks.vue
Normal file
733
docs/.vitepress/theme/components/startpage/Bookmarks.vue
Normal 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>
|
29
docs/.vitepress/theme/components/startpage/Clock.vue
Normal file
29
docs/.vitepress/theme/components/startpage/Clock.vue
Normal 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>
|
194
docs/.vitepress/theme/components/startpage/SearchBar.vue
Normal file
194
docs/.vitepress/theme/components/startpage/SearchBar.vue
Normal 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>
|
49
docs/.vitepress/theme/components/startpage/Startpage.vue
Normal file
49
docs/.vitepress/theme/components/startpage/Startpage.vue
Normal 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>
|
|
@ -318,38 +318,32 @@ const transformLinks = (text: string): string =>
|
||||||
{
|
{
|
||||||
name: 'Windows',
|
name: 'Windows',
|
||||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Windows(?=,|[ \t]\/|$)/gm,
|
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Windows(?=,|[ \t]\/|$)/gm,
|
||||||
replace:
|
replace: ' <div alt="Windows" class="i-qlementine-icons:windows-24" /> '
|
||||||
' <div alt="Windows" class="i-qlementine-icons:windows-24" /> '
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Mac',
|
name: 'Mac',
|
||||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Mac(?=,|[ \t]\/|$)/gm,
|
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Mac(?=,|[ \t]\/|$)/gm,
|
||||||
replace:
|
replace: ' <div alt="Mac" class="i-qlementine-icons:mac-fill-16" /> '
|
||||||
' <div alt="Mac" class="i-qlementine-icons:mac-fill-16" /> '
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Linux',
|
name: 'Linux',
|
||||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Linux(?=,|[ \t]\/|$)/gm,
|
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Linux(?=,|[ \t]\/|$)/gm,
|
||||||
replace:
|
replace: ' <div alt="Linux" class="i-fluent-mdl2:linux-logo-32" /> '
|
||||||
' <div alt="Linux" class="i-fluent-mdl2:linux-logo-32" /> '
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Android',
|
name: 'Android',
|
||||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Android(?=,|[ \t]\/|$)/gm,
|
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Android(?=,|[ \t]\/|$)/gm,
|
||||||
replace:
|
replace: ' <div alt="Android" class="i-material-symbols:android" /> '
|
||||||
' <div alt="Android" class="i-material-symbols:android" /> '
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'iOS',
|
name: 'iOS',
|
||||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)iOS(?=,|[ \t]\/|$)/gm,
|
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)iOS(?=,|[ \t]\/|$)/gm,
|
||||||
replace:
|
replace: ' <div alt="iOS" class="i-simple-icons:ios" /> '
|
||||||
' <div alt="iOS" class="i-simple-icons:ios" /> '
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Web',
|
name: 'Web',
|
||||||
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Web(?=,|[ \t]\/|$)/gm,
|
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Web(?=,|[ \t]\/|$)/gm,
|
||||||
replace:
|
replace: ' <div alt="Web" class="i-fluent:globe-32-filled" /> '
|
||||||
' <div alt="Web" class="i-fluent:globe-32-filled" /> '
|
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
.getText()
|
.getText()
|
||||||
|
|
|
@ -22,7 +22,7 @@ interface Header {
|
||||||
export const headers: Header = {
|
export const headers: Header = {
|
||||||
'adblockvpnguide.md': {
|
'adblockvpnguide.md': {
|
||||||
title: 'Adblocking / Privacy',
|
title: 'Adblocking / Privacy',
|
||||||
description: "Adblocking, Privacy, VPNs, Proxies, Antiviruses"
|
description: 'Adblocking, Privacy, VPNs, Proxies, Antiviruses'
|
||||||
},
|
},
|
||||||
'ai.md': {
|
'ai.md': {
|
||||||
title: 'Artificial Intelligence',
|
title: 'Artificial Intelligence',
|
||||||
|
@ -139,7 +139,8 @@ export const excluded = [
|
||||||
'single-page',
|
'single-page',
|
||||||
'feedback.md',
|
'feedback.md',
|
||||||
'index.md',
|
'index.md',
|
||||||
'sandbox.md'
|
'sandbox.md',
|
||||||
|
'startpage.md'
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getHeader(id: string) {
|
export function getHeader(id: string) {
|
||||||
|
|
11
docs/startpage.md
Normal file
11
docs/startpage.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
layout: false
|
||||||
|
title: Startpage
|
||||||
|
pageClass: startpage-custom-styling
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import StartPage from './.vitepress/theme/components/startpage/Startpage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StartPage />
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@fmhy/website",
|
"name": "@fmhy/website",
|
||||||
"packageManager": "pnpm@10.11.0",
|
"packageManager": "pnpm@10.12.2+sha256.07b2396c6c99a93b75b5f9ce22be9285c3b2533c49fec51b349d44798cf56b82",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "21.7.3"
|
"node": "21.7.3"
|
||||||
|
@ -32,7 +32,7 @@
|
||||||
"nitropack": "^2.11.6",
|
"nitropack": "^2.11.6",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pathe": "^2.0.1",
|
"pathe": "^2.0.1",
|
||||||
"reka-ui": "^2.1.1",
|
"reka-ui": "^2.3.1",
|
||||||
"unocss": "66.1.0-beta.3",
|
"unocss": "66.1.0-beta.3",
|
||||||
"vitepress": "^1.6.3",
|
"vitepress": "^1.6.3",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
@ -47,6 +47,7 @@
|
||||||
"@iconify-json/fluent-mdl2": "^1.2.1",
|
"@iconify-json/fluent-mdl2": "^1.2.1",
|
||||||
"@iconify-json/gravity-ui": "^1.2.5",
|
"@iconify-json/gravity-ui": "^1.2.5",
|
||||||
"@iconify-json/heroicons-solid": "^1.2.0",
|
"@iconify-json/heroicons-solid": "^1.2.0",
|
||||||
|
"@iconify-json/logos": "^1.2.4",
|
||||||
"@iconify-json/lucide": "^1.2.10",
|
"@iconify-json/lucide": "^1.2.10",
|
||||||
"@iconify-json/material-symbols": "^1.2.22",
|
"@iconify-json/material-symbols": "^1.2.22",
|
||||||
"@iconify-json/mdi": "^1.2.1",
|
"@iconify-json/mdi": "^1.2.1",
|
||||||
|
|
|
@ -36,9 +36,7 @@
|
||||||
|
|
||||||
/* Skip type checking all .d.ts files. */
|
/* Skip type checking all .d.ts files. */
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": [
|
"types": ["./worker-configuration.d.ts"]
|
||||||
"./worker-configuration.d.ts"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"exclude": ["test"],
|
"exclude": ["test"],
|
||||||
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
"include": ["worker-configuration.d.ts", "src/**/*.ts"]
|
||||||
|
|
12809
pests-repellent/worker-configuration.d.ts
vendored
12809
pests-repellent/worker-configuration.d.ts
vendored
File diff suppressed because it is too large
Load diff
|
@ -10,9 +10,7 @@
|
||||||
"observability": {
|
"observability": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
},
|
},
|
||||||
"routes": [
|
"routes": ["fmhy.net/*"],
|
||||||
"fmhy.net/*"
|
|
||||||
],
|
|
||||||
/**
|
/**
|
||||||
* Smart Placement
|
* Smart Placement
|
||||||
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
||||||
|
|
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
@ -45,8 +45,8 @@ importers:
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
reka-ui:
|
reka-ui:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.3.1
|
||||||
version: 2.1.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
|
version: 2.3.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
|
||||||
unocss:
|
unocss:
|
||||||
specifier: 66.1.0-beta.3
|
specifier: 66.1.0-beta.3
|
||||||
version: 66.1.0-beta.3(vite@5.4.14(@types/node@20.16.12)(sass@1.85.1)(terser@5.39.0))(vue@3.5.13(typescript@5.8.2))
|
version: 66.1.0-beta.3(vite@5.4.14(@types/node@20.16.12)(sass@1.85.1)(terser@5.39.0))(vue@3.5.13(typescript@5.8.2))
|
||||||
|
@ -84,6 +84,9 @@ importers:
|
||||||
'@iconify-json/heroicons-solid':
|
'@iconify-json/heroicons-solid':
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
|
'@iconify-json/logos':
|
||||||
|
specifier: ^1.2.4
|
||||||
|
version: 1.2.4
|
||||||
'@iconify-json/lucide':
|
'@iconify-json/lucide':
|
||||||
specifier: ^1.2.10
|
specifier: ^1.2.10
|
||||||
version: 1.2.10
|
version: 1.2.10
|
||||||
|
@ -1048,6 +1051,9 @@ packages:
|
||||||
'@iconify-json/heroicons-solid@1.2.0':
|
'@iconify-json/heroicons-solid@1.2.0':
|
||||||
resolution: {integrity: sha512-o+PjtMXPr4wk0veDS7Eh6H1BnTJT1vD7HcKl+I7ixdYQC8i1P2zdtk0C2v7C9OjJBMsiwJSCxT4qQ3OzONgyjw==}
|
resolution: {integrity: sha512-o+PjtMXPr4wk0veDS7Eh6H1BnTJT1vD7HcKl+I7ixdYQC8i1P2zdtk0C2v7C9OjJBMsiwJSCxT4qQ3OzONgyjw==}
|
||||||
|
|
||||||
|
'@iconify-json/logos@1.2.4':
|
||||||
|
resolution: {integrity: sha512-XC4If5D/hbaZvUkTV8iaZuGlQCyG6CNOlaAaJaGa13V5QMYwYjgtKk3vPP8wz3wtTVNVEVk3LRx1fOJz+YnSMw==}
|
||||||
|
|
||||||
'@iconify-json/lucide@1.2.10':
|
'@iconify-json/lucide@1.2.10':
|
||||||
resolution: {integrity: sha512-cR1xpRJ4dnoXlC0ShDjzbrZyu+ICH4OUaYl7S51MhZUO1H040s7asVqv0LsDbofSLDuzWkHCLsBabTTRL0mCUg==}
|
resolution: {integrity: sha512-cR1xpRJ4dnoXlC0ShDjzbrZyu+ICH4OUaYl7S51MhZUO1H040s7asVqv0LsDbofSLDuzWkHCLsBabTTRL0mCUg==}
|
||||||
|
|
||||||
|
@ -3415,8 +3421,8 @@ packages:
|
||||||
regex@6.0.1:
|
regex@6.0.1:
|
||||||
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
|
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
|
||||||
|
|
||||||
reka-ui@2.1.1:
|
reka-ui@2.3.1:
|
||||||
resolution: {integrity: sha512-awvpQ041LPXAvf2uRVFwedsyz9SwsuoWlRql1fg4XimUCxEI2GOfHo6FIdL44dSPb/eG/gWbdGhoGHLlbX5gPA==}
|
resolution: {integrity: sha512-2SjGeybd7jvD8EQUkzjgg7GdOQdf4cTwdVMq/lDNTMqneUFNnryGO43dg8WaM/jaG9QpSCZBvstfBFWlDdb2Zg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: '>= 3.2.0'
|
vue: '>= 3.2.0'
|
||||||
|
|
||||||
|
@ -4843,6 +4849,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify-json/logos@1.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/lucide@1.2.10':
|
'@iconify-json/lucide@1.2.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
@ -7281,7 +7291,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
regex-utilities: 2.3.0
|
regex-utilities: 2.3.0
|
||||||
|
|
||||||
reka-ui@2.1.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)):
|
reka-ui@2.3.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.6.13
|
'@floating-ui/dom': 1.6.13
|
||||||
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.8.2))
|
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.8.2))
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
presetAttributify,
|
presetAttributify,
|
||||||
presetIcons,
|
presetIcons,
|
||||||
presetUno,
|
presetUno,
|
||||||
|
presetWebFonts,
|
||||||
transformerDirectives
|
transformerDirectives
|
||||||
} from 'unocss'
|
} from 'unocss'
|
||||||
|
|
||||||
|
@ -94,16 +95,44 @@ export default defineConfig({
|
||||||
// Color scale utilities
|
// Color scale utilities
|
||||||
...createColorRules('text'),
|
...createColorRules('text'),
|
||||||
...createColorRules('bg'),
|
...createColorRules('bg'),
|
||||||
...createColorRules('border')
|
...createColorRules('border'),
|
||||||
|
|
||||||
|
[
|
||||||
|
'kbd',
|
||||||
|
{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.2em 0.4em',
|
||||||
|
fontSize: '0.75em',
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: '1',
|
||||||
|
color: 'var(--vp-c-text-1)',
|
||||||
|
backgroundColor: 'rgb(var(--vp-c-bg-alt))',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
presets: [
|
presets: [
|
||||||
presetUno(),
|
presetUno(),
|
||||||
presetAttributify(),
|
presetAttributify(),
|
||||||
presetIcons({
|
presetIcons({
|
||||||
|
autoInstall: true,
|
||||||
scale: 1.2,
|
scale: 1.2,
|
||||||
extraProperties: {
|
extraProperties: {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
'vertical-align': 'middle'
|
'vertical-align': 'middle'
|
||||||
|
},
|
||||||
|
collections: {
|
||||||
|
custom: {
|
||||||
|
privateersclub: () =>
|
||||||
|
fetch('https://megathread.pages.dev/favicon.svg').then((r) =>
|
||||||
|
r.text()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
presetWebFonts({
|
||||||
|
fonts: {
|
||||||
|
mono: 'Geist Mono'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue