feat: startpage

This commit is contained in:
taskylizard 2025-06-24 17:36:01 +00:00
parent 6418da63ba
commit 8d6c3ace0b
No known key found for this signature in database
GPG key ID: 1820131ED1A24120
19 changed files with 7804 additions and 6133 deletions

View file

@ -10,6 +10,5 @@ services:
ports:
- '4173:4173'
networks:
fmhy:

View file

@ -152,8 +152,10 @@ export default defineConfig({
search,
footer: {
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: {
pattern: 'https://github.com/fmhy/edit/edit/main/docs/:path',
text: '📝 Edit this page'

View file

@ -153,7 +153,10 @@ export const nav: DefaultTheme.NavItem[] = [
{ text: '🔖 Bookmarks', link: 'https://github.com/fmhy/bookmarks' },
{ text: '✅ SafeGuard', link: 'https://github.com/fmhy/FMHY-SafeGuard' },
{ 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: '💡 Site Hunting',

View file

@ -266,7 +266,8 @@ const toggleCard = () => (isCardShown.value = !isCardShown.value)
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
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/"

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>

View file

@ -318,38 +318,32 @@ const transformLinks = (text: string): string =>
{
name: 'Windows',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Windows(?=,|[ \t]\/|$)/gm,
replace:
' <div alt="Windows" class="i-qlementine-icons:windows-24" /> '
replace: ' <div alt="Windows" class="i-qlementine-icons:windows-24" /> '
},
{
name: 'Mac',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Mac(?=,|[ \t]\/|$)/gm,
replace:
' <div alt="Mac" class="i-qlementine-icons:mac-fill-16" /> '
replace: ' <div alt="Mac" class="i-qlementine-icons:mac-fill-16" /> '
},
{
name: 'Linux',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Linux(?=,|[ \t]\/|$)/gm,
replace:
' <div alt="Linux" class="i-fluent-mdl2:linux-logo-32" /> '
replace: ' <div alt="Linux" class="i-fluent-mdl2:linux-logo-32" /> '
},
{
name: 'Android',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Android(?=,|[ \t]\/|$)/gm,
replace:
' <div alt="Android" class="i-material-symbols:android" /> '
replace: ' <div alt="Android" class="i-material-symbols:android" /> '
},
{
name: 'iOS',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)iOS(?=,|[ \t]\/|$)/gm,
replace:
' <div alt="iOS" class="i-simple-icons:ios" /> '
replace: ' <div alt="iOS" class="i-simple-icons:ios" /> '
},
{
name: 'Web',
find: /(?<=\/ (\/>|[^/\r\n])*)(,\s)?(?<![a-z]\s)Web(?=,|[ \t]\/|$)/gm,
replace:
' <div alt="Web" class="i-fluent:globe-32-filled" /> '
replace: ' <div alt="Web" class="i-fluent:globe-32-filled" /> '
}
])
.getText()

View file

@ -22,7 +22,7 @@ interface Header {
export const headers: Header = {
'adblockvpnguide.md': {
title: 'Adblocking / Privacy',
description: "Adblocking, Privacy, VPNs, Proxies, Antiviruses"
description: 'Adblocking, Privacy, VPNs, Proxies, Antiviruses'
},
'ai.md': {
title: 'Artificial Intelligence',
@ -139,7 +139,8 @@ export const excluded = [
'single-page',
'feedback.md',
'index.md',
'sandbox.md'
'sandbox.md',
'startpage.md'
]
export function getHeader(id: string) {

11
docs/startpage.md Normal file
View 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 />

View file

@ -1,6 +1,6 @@
{
"name": "@fmhy/website",
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.12.2+sha256.07b2396c6c99a93b75b5f9ce22be9285c3b2533c49fec51b349d44798cf56b82",
"type": "module",
"engines": {
"node": "21.7.3"
@ -32,7 +32,7 @@
"nitropack": "^2.11.6",
"nprogress": "^0.2.0",
"pathe": "^2.0.1",
"reka-ui": "^2.1.1",
"reka-ui": "^2.3.1",
"unocss": "66.1.0-beta.3",
"vitepress": "^1.6.3",
"vue": "^3.5.13",
@ -47,6 +47,7 @@
"@iconify-json/fluent-mdl2": "^1.2.1",
"@iconify-json/gravity-ui": "^1.2.5",
"@iconify-json/heroicons-solid": "^1.2.0",
"@iconify-json/logos": "^1.2.4",
"@iconify-json/lucide": "^1.2.10",
"@iconify-json/material-symbols": "^1.2.22",
"@iconify-json/mdi": "^1.2.1",

View file

@ -2,4 +2,4 @@
"files.associations": {
"wrangler.json": "jsonc"
}
}
}

View file

@ -15,4 +15,4 @@
"vitest": "~3.2.0",
"wrangler": "^4.20.5"
}
}
}

View file

@ -36,9 +36,7 @@
/* Skip type checking all .d.ts files. */
"skipLibCheck": true,
"types": [
"./worker-configuration.d.ts"
]
"types": ["./worker-configuration.d.ts"]
},
"exclude": ["test"],
"include": ["worker-configuration.d.ts", "src/**/*.ts"]

File diff suppressed because it is too large Load diff

View file

@ -10,9 +10,7 @@
"observability": {
"enabled": true,
},
"routes": [
"fmhy.net/*"
],
"routes": ["fmhy.net/*"],
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement

20
pnpm-lock.yaml generated
View file

@ -45,8 +45,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1
reka-ui:
specifier: ^2.1.1
version: 2.1.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
specifier: ^2.3.1
version: 2.3.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
unocss:
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))
@ -84,6 +84,9 @@ importers:
'@iconify-json/heroicons-solid':
specifier: ^1.2.0
version: 1.2.0
'@iconify-json/logos':
specifier: ^1.2.4
version: 1.2.4
'@iconify-json/lucide':
specifier: ^1.2.10
version: 1.2.10
@ -1048,6 +1051,9 @@ packages:
'@iconify-json/heroicons-solid@1.2.0':
resolution: {integrity: sha512-o+PjtMXPr4wk0veDS7Eh6H1BnTJT1vD7HcKl+I7ixdYQC8i1P2zdtk0C2v7C9OjJBMsiwJSCxT4qQ3OzONgyjw==}
'@iconify-json/logos@1.2.4':
resolution: {integrity: sha512-XC4If5D/hbaZvUkTV8iaZuGlQCyG6CNOlaAaJaGa13V5QMYwYjgtKk3vPP8wz3wtTVNVEVk3LRx1fOJz+YnSMw==}
'@iconify-json/lucide@1.2.10':
resolution: {integrity: sha512-cR1xpRJ4dnoXlC0ShDjzbrZyu+ICH4OUaYl7S51MhZUO1H040s7asVqv0LsDbofSLDuzWkHCLsBabTTRL0mCUg==}
@ -3415,8 +3421,8 @@ packages:
regex@6.0.1:
resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==}
reka-ui@2.1.1:
resolution: {integrity: sha512-awvpQ041LPXAvf2uRVFwedsyz9SwsuoWlRql1fg4XimUCxEI2GOfHo6FIdL44dSPb/eG/gWbdGhoGHLlbX5gPA==}
reka-ui@2.3.1:
resolution: {integrity: sha512-2SjGeybd7jvD8EQUkzjgg7GdOQdf4cTwdVMq/lDNTMqneUFNnryGO43dg8WaM/jaG9QpSCZBvstfBFWlDdb2Zg==}
peerDependencies:
vue: '>= 3.2.0'
@ -4843,6 +4849,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/logos@1.2.4':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/lucide@1.2.10':
dependencies:
'@iconify/types': 2.0.0
@ -7281,7 +7291,7 @@ snapshots:
dependencies:
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:
'@floating-ui/dom': 1.6.13
'@floating-ui/vue': 1.1.6(vue@3.5.13(typescript@5.8.2))

View file

@ -21,6 +21,7 @@ import {
presetAttributify,
presetIcons,
presetUno,
presetWebFonts,
transformerDirectives
} from 'unocss'
@ -94,16 +95,44 @@ export default defineConfig({
// Color scale utilities
...createColorRules('text'),
...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: [
presetUno(),
presetAttributify(),
presetIcons({
autoInstall: true,
scale: 1.2,
extraProperties: {
display: 'inline-block',
'vertical-align': 'middle'
},
collections: {
custom: {
privateersclub: () =>
fetch('https://megathread.pages.dev/favicon.svg').then((r) =>
r.text()
)
}
}
}),
presetWebFonts({
fonts: {
mono: 'Geist Mono'
}
})
],