🎉 New website!

This commit is contained in:
taskylizard
2023-11-12 22:12:57 +05:30
parent 28f602b9ad
commit c97a0abf92
68 changed files with 8433 additions and 353 deletions

135
.vitepress/config.mts Normal file
View File

@@ -0,0 +1,135 @@
import { defineConfig } from "vitepress";
import { withPwa } from "@vite-pwa/vitepress";
import UnoCSS from "unocss/vite";
import { presetUno, presetAttributify, presetIcons } from "unocss";
import { commitRef, meta } from "./constants";
import { pwa } from "./pwa";
import { fileURLToPath } from "url";
import { generateImages, generateMeta } from "./hooks";
import { toggleStarredPlugin } from "./markdown/toggleStarred";
import { base64DecodePlugin } from "./markdown/base64";
export default withPwa(
defineConfig({
title: "FMHY",
description: meta.description,
titleTemplate: ":title • freemediaheckyeah",
lang: "en-US",
lastUpdated: true,
cleanUrls: true,
appearance: "dark",
srcExclude: ["readme.md", "single-page", "toolsguide.md"],
ignoreDeadLinks: true,
metaChunk: true,
sitemap: {
hostname: meta.hostname,
},
head: [
["meta", { name: "theme-color", content: "#7bc5e4" }],
["meta", { name: "og:type", content: "website" }],
["meta", { name: "og:locale", content: "en" }],
["link", { rel: "icon", href: "/test.png" }],
// PWA
["link", { rel: "icon", href: "/test.png", type: "image/svg+xml" }],
["link", { rel: "alternate icon", href: "/test.png" }],
["link", { rel: "mask-icon", href: "/test.png", color: "#7bc5e4" }],
// prettier-ignore
["meta", { name: "keywords", content: meta.keywords.join(" ") }],
["link", { rel: "apple-touch-icon", href: "/test.png", sizes: "192x192" }],
],
transformHead: async (context) => generateMeta(context, meta.hostname),
buildEnd: async (context) => {
generateImages(context);
},
vite: {
plugins: [
UnoCSS({
presets: [
presetUno(),
presetAttributify(),
presetIcons({
scale: 1.2,
extraProperties: {
display: "inline-block",
"vertical-align": "middle",
},
}),
],
}),
],
build: {
// Shut the fuck up
chunkSizeWarningLimit: Infinity,
},
resolve: {
alias: [
{
find: /^.*VPSwitchAppearance\.vue$/,
replacement: fileURLToPath(
new URL("./theme/components/ThemeSwitch.vue", import.meta.url),
),
},
],
},
},
markdown: {
config(md) {
md.use(toggleStarredPlugin);
md.use(base64DecodePlugin);
},
},
themeConfig: {
search: {
options: {
detailedView: true,
},
provider: "local",
},
footer: {
message: `Made with ❤️ (rev: ${commitRef})`,
},
outline: "deep",
logo: "/fmhy.ico",
nav: [
{ text: "Beginners Guide", link: "/beginners-guide" },
{ text: "Glossary", link: "https://rentry.org/The-Piracy-Glossary" },
{ text: "Guides", link: "https://rentry.co/fmhy-guides" },
{ text: "Backups", link: "https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/backups" },
{ text: "Updates", link: "https://www.reddit.com/r/FREEMEDIAHECKYEAH/wiki/updates/" },
],
sidebar: [
{ text: "📛 Adblocking / Privacy", link: "/adblockvpnguide" },
{ text: "🤖 Artificial Intelligence", link: "/ai" },
{ text: "📺 Movies / TV / Anime", link: "/videopiracyguide" },
{ text: "🎵 Music / Podcasts / Radio", link: "/audiopiracyguide" },
{ text: "🎮 Gaming / Emulation", link: "/gamingpiracyguide" },
{ text: "📗 Books / Comics / Manga", link: "/readingpiracyguide" },
{ text: "💾 Downloading", link: "/downloadpiracyguide" },
{ text: "🌀 Torrenting", link: "/torrentpiracyguide" },
{ text: "🧠 Educational", link: "/edupiracyguide" },
{ text: "🔧 Tools", link: "/toolsguide" },
{ text: "📷 Image Tools", link: "/img-tools" },
{ text: "👨‍💻 Developer Tools", link: "/devtools" },
{ text: "📱 Android / iOS", link: "/android-iosguide" },
{ text: "🐧 Linux / MacOS", link: "/linuxguide" },
{ text: "🌍 Non-English", link: "/non-english" },
{ text: "📂 Miscellaneous", link: "/miscguide" },
{ text: "🔞 NSFW", link: "/nsfwpiracy" },
{ text: "⚠️ Unsafe Sites", link: "/unsafesites" },
{ text: "🔑 Base64", link: "/base64" },
{ text: "📦 Storage", link: "/storage" },
],
socialLinks: [
{ icon: "github", link: "https://github.com/fmhy/FMHYEdit" },
{ icon: "discord", link: "https://discord.gg/Stz6y6NgNg" },
{
icon: {
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12a12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547l-.8 3.747c1.824.07 3.48.632 4.674 1.488c.308-.309.73-.491 1.207-.491c.968 0 1.754.786 1.754 1.754c0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87c-3.874 0-7.004-2.176-7.004-4.87c0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754c.463 0 .898.196 1.207.49c1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197a.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248c.687 0 1.248-.561 1.248-1.249c0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25c0 .687.561 1.248 1.249 1.248c.688 0 1.249-.561 1.249-1.249c0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094a.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913c.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463a.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73c-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>',
},
link: "https://reddit.com/r/FREEMEDIAHECKYEAH",
},
],
...pwa,
},
}),
);

16
.vitepress/constants.ts Normal file
View File

@@ -0,0 +1,16 @@
export const meta = {
name: "FreeMediaHeckYeah",
description:
"The Largest Collection Of Free Stuff On The Internet!The Largest Collection Of Free Stuff On The Internet!",
hostname: process.env.COMMIT_REF ? "https://fmhy.netlify.app" : "https://fmhy.pages.dev",
keywords: ["stream", "movies", "gaming", "reading", "anime"],
};
// Netlify to Cloudflare otherwise dev
export const commitRef = process.env.COMMIT_REF
? `<a href="https://github.com/fmhy/FMHYEdit/commit/${process.env.COMMIT_REF
}">${process.env.COMMIT_REF.slice(0, 8)}</a>`
: process.env.CF_PAGES_COMMIT_SHA
? `<a href="https://github.com/fmhy/FMHYEdit/commit/${process.env.CF_PAGES_COMMIT_SHA
}">${process.env.CF_PAGES_COMMIT_SHA.slice(0, 8)}</a>`
: "dev";

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string }>();
</script>
<template>
<div
tw="w-full h-full bg-black flex flex-col"
style="background-image: url(https://files.catbox.moe/1f84dy.png)">
<div tw="p-10 w-full min-h-0 grow flex flex-col items-center justify-between">
<div tw="w-full flex justify-between items-center text-5xl font-medium">
<div tw="flex items-center">
<div tw="text-zinc-100 ml-2 mt-1 font-semibold">freemediaheckyeah</div>
</div>
</div>
<div tw="w-full pr-56 flex flex-col items-start justify-end">
<div style="color: #f3f4f6" tw="text-6xl font-bold" v-html="title" />
<div style="color: #c0caf5" tw="mt-2 text-4xl" v-html="description" />
</div>
</div>
<div tw="shrink-0 h-2 w-full flex" style="background-color: #c4b5fd" />
</div>
</template>

View File

@@ -0,0 +1,6 @@
/**
* Barrel generated using @taskylizard/tasker.
*/
export * from "./meta";
export * from "./opengraph";

84
.vitepress/hooks/meta.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { HeadConfig, TransformContext } from "vitepress";
export function generateMeta(context: TransformContext, hostname: string) {
const head: HeadConfig[] = [];
const { pageData } = context;
const url = `${hostname}/${pageData.relativePath.replace(/((^|\/)index)?\.md$/, "$2")}`;
head.push(["link", { rel: "canonical", href: url }]);
head.push(["meta", { property: "og:url", content: url }]);
head.push(["meta", { name: "twitter:url", content: url }]);
head.push(["meta", { name: "twitter:card", content: "summary_large_image" }]);
head.push(["meta", { property: "og:title", content: pageData.frontmatter.title }]);
head.push(["meta", { name: "twitter:title", content: pageData.frontmatter.title }]);
head.push([
"meta",
{
property: "og:description",
content: pageData.frontmatter.description,
},
]);
head.push([
"meta",
{
name: "twitter:description",
content: pageData.frontmatter.description,
},
]);
if (pageData.frontmatter.image) {
head.push([
"meta",
{
property: "og:image",
content: `${hostname}/${pageData.frontmatter.image.replace(/^\//, "")}`,
},
]);
head.push([
"meta",
{
name: "twitter:image",
content: `${hostname}/${pageData.frontmatter.image.replace(/^\//, "")}`,
},
]);
} else {
const url = pageData.filePath.replace("index.md", "").replace(".md", "");
const imageUrl = `${url}/__og_image__/og.png`.replace(/\/\//g, "/").replace(/^\//, "");
head.push(["meta", { property: "og:image", content: `${hostname}/${imageUrl}` }]);
head.push(["meta", { property: "og:image:width", content: "1200" }]);
head.push(["meta", { property: "og:image:height", content: "628" }]);
head.push(["meta", { property: "og:image:type", content: "image/png" }]);
head.push(["meta", { property: "og:image:alt", content: pageData.frontmatter.title }]);
head.push(["meta", { name: "twitter:image", content: `${hostname}/${imageUrl}` }]);
head.push(["meta", { name: "twitter:image:width", content: "1200" }]);
head.push(["meta", { name: "twitter:image:height", content: "628" }]);
head.push(["meta", { name: "twitter:image:alt", content: pageData.frontmatter.title }]);
}
if (pageData.frontmatter.tag) {
head.push(["meta", { property: "article:tag", content: pageData.frontmatter.tag }]);
}
if (pageData.frontmatter.date) {
head.push([
"meta",
{
property: "article:published_time",
content: pageData.frontmatter.date,
},
]);
}
if (pageData.lastUpdated && pageData.frontmatter.lastUpdated !== false) {
head.push([
"meta",
{
property: "article:modified_time",
content: new Date(pageData.lastUpdated).toISOString(),
},
]);
}
return head;
}

View File

@@ -0,0 +1,89 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createContentLoader } from "vitepress";
import type { ContentData, SiteConfig } from "vitepress";
import { type SatoriOptions, satoriVue } from "x-satori/vue";
import { renderAsync } from "@resvg/resvg-js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const __fonts = resolve(__dirname, "../fonts");
export async function generateImages(config: SiteConfig) {
const pages = await createContentLoader("**/*.md", { excerpt: true }).load();
const template = await readFile(resolve(__dirname, "./Template.vue"), "utf-8");
const fonts: SatoriOptions["fonts"] = [
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-Regular.otf")),
weight: 400,
style: "normal",
},
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-Medium.otf")),
weight: 500,
style: "normal",
},
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-SemiBold.otf")),
weight: 600,
style: "normal",
},
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-Bold.otf")),
weight: 700,
style: "normal",
},
];
for (const page of pages) {
await generateImage({
page,
template,
outDir: config.outDir,
fonts,
});
}
}
interface GenerateImagesOptions {
page: ContentData;
template: string;
outDir: string;
fonts: SatoriOptions["fonts"];
}
async function generateImage({ page, template, outDir, fonts }: GenerateImagesOptions) {
const { frontmatter, url } = page;
const options: SatoriOptions = {
width: 1200,
height: 628,
fonts,
props: {
title:
frontmatter.layout === "home"
? frontmatter.hero.name ?? frontmatter.title
: frontmatter.title,
description:
frontmatter.layout === "home"
? frontmatter.hero.tagline ?? frontmatter.description
: frontmatter.description,
},
};
const svg = await satoriVue(options, template);
const render = await renderAsync(svg);
const outputFolder = resolve(outDir, url.substring(1), "__og_image__");
const outputFile = resolve(outputFolder, "og.png");
await mkdir(outputFolder, { recursive: true });
return await writeFile(outputFile, render.asPng());
}

View File

@@ -0,0 +1,45 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { SatoriOptions, defineSatoriConfig } from "x-satori/vue";
const __dirname = dirname(fileURLToPath(import.meta.url));
const __fonts = resolve(__dirname, "../fonts");
const fonts: SatoriOptions["fonts"] = [
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-Regular.otf")),
weight: 400,
style: "normal",
},
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-Medium.otf")),
weight: 500,
style: "normal",
},
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-SemiBold.otf")),
weight: 600,
style: "normal",
},
{
name: "Inter",
data: await readFile(resolve(__fonts, "Inter-Bold.otf")),
weight: 700,
style: "normal",
},
];
export default defineSatoriConfig({
width: 1200,
height: 628,
fonts,
props: {
title: "Title",
description:
"Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.",
dir: "/j",
},
});

View File

@@ -0,0 +1,46 @@
import { defineLoader } from "vitepress";
import { writeFile, readFile } from "fs/promises";
interface Data {
title?: string;
content?: string;
url?: string;
}
declare const data: Data;
export { data };
const page = "https://rentry.co/fmhy-guides/raw";
const regex = /\* \[([^\]]+)\]\(([^)]+)\)/g;
const rentryRe = /(?<=rentry\.(co|org)).*/;
const guides = new Set<Data>();
const f = async (url: string) => {
const contents = await (await fetch(url))
.text()
.catch((error: Error) => console.error(`Failed at ${url}`, error));
return contents;
};
export default defineLoader({
async load(): Promise<Data> {
const contents = await f(page);
let match: any[] | null;
while ((match = regex.exec(contents)) !== null) {
const title = match[1];
const url = match[2];
// Fetch rentry guides
if (url.match(rentryRe)) {
const content = await f(url + "/raw");
guides.add({ title, content });
} else {
// Everything else can be here
guides.add({ title, url });
}
}
const obj = Object.fromEntries(
[...guides.entries()].map((entry, index) => [index.toString(), entry]),
);
await writeFile("./guides.json", JSON.stringify(obj, null, 4), "utf-8");
return JSON.parse(await readFile("./guides.json", { encoding: "utf-8" })) as Data;
},
});

View File

@@ -0,0 +1,25 @@
import { type MarkdownRenderer } from "vitepress";
// FIXME: tasky: possibly write less horror jank?
export function base64DecodePlugin(md: MarkdownRenderer) {
const decode = (str: string): string => Buffer.from(str, "base64").toString("binary");
// Save the original rule for backticks
const defaultRender =
md.renderer.rules.code_inline ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.code_inline = function (tokens, idx, options, env, self) {
// @ts-expect-error shut the fuck up already I HATE THIS
if (!env.frontmatter.title || (env.frontmatter.title && !env.frontmatter.title === "base64")) {
return defaultRender(tokens, idx, options, env, self);
}
const token = tokens[idx];
const content = token.content;
return `<button class='base64' onclick="(function(btn){ const codeEl = btn.querySelector('code'); navigator.clipboard.writeText('${decode(
content,
)}').then(() => { const originalText = codeEl.textContent; codeEl.textContent = 'Copied'; setTimeout(() => codeEl.textContent = originalText, 3000); }).catch(console.error); })(this)"><code>${content}</code></button>`;
};
}

View File

@@ -0,0 +1,17 @@
import type { MarkdownRenderer } from "vitepress";
const excluded = ["Beginners Guide"];
export function toggleStarredPlugin(md: MarkdownRenderer) {
md.renderer.rules.list_item_open = (tokens, index, options, env, self) => {
const contentToken = tokens[index + 2];
if (
!excluded.includes(env.frontmatter.title) &&
contentToken &&
contentToken.content.startsWith("⭐")
) {
return `<li class="starred">`;
}
return self.renderToken(tokens, index, options);
};
}

View File

@@ -0,0 +1,6 @@
import { corsEventHandler } from "nitro-cors";
export default corsEventHandler((_event) => {}, {
origin: "*",
methods: "*",
});

102
.vitepress/pwa.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { PwaOptions } from "@vite-pwa/vitepress";
import { meta } from "./constants";
import { resolve } from "pathe";
import fg from "fast-glob";
export const pwa = {
outDir: ".vitepress/dist",
registerType: "autoUpdate",
includeManifestIcons: false,
includeAssets: fg.sync("**/*.{png,webp,svg,gif,ico,txt}", {
cwd: resolve(__dirname, "../public"),
}),
manifest: {
id: "/",
name: meta.name,
short_name: meta.name,
description: meta.description,
theme_color: "#ffffff",
start_url: "/",
lang: "en-US",
dir: "ltr",
orientation: "natural",
display: "standalone",
display_override: ["window-controls-overlay"],
categories: meta.keywords,
// TODO: replace with actual icons
icons: [
{
src: "test.png",
sizes: "64x64",
type: "image/png",
},
{
src: "test.png",
sizes: "192x192",
type: "image/png",
},
{
src: "test.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "maskable-icon.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
handle_links: "preferred",
launch_handler: {
client_mode: ["navigate-existing", "auto"],
},
edge_side_panel: {
preferred_width: 480,
},
},
experimental: {
includeAllowlist: true,
},
workbox: {
globPatterns: ["**/*.{css,js,html,svg,png,ico,txt,woff2,json}"],
globIgnores: ["**/404.html"],
navigateFallback: null,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "gstatic-fonts-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // <== 365 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
devOptions: {
enabled: true,
suppressWarnings: false,
},
} satisfies PwaOptions;

View File

@@ -0,0 +1,49 @@
import { fetcher } from "itty-fetcher";
import { FeedbackSchema } from "../types/Feedback";
const feedbackOptions = [
{ label: "🐞 Bug", value: "bug" },
{
label: "♻️ Suggestion",
value: "suggestion",
},
{ label: "📂 Other", value: "other" },
{
label: "❤️ Appreciation",
value: "appreciate",
},
];
function getFeedbackOption(value: string) {
return feedbackOptions.find((option) => option.value === value);
}
export default defineEventHandler(async (event) => {
const { message, page, contact, type } = await readValidatedBody(event, FeedbackSchema.parse);
const env = useRuntimeConfig(event);
if (!["bug", "suggestion", "other", "appreciate"].includes(type!) || !message)
throw new Error("Invalid input.");
let description = `${message}\n\n`;
if (contact) description += `**Contact:** ${contact}`;
if (page) description += `**Page:** \`${page}\``;
await fetcher()
.post(env.WEBHOOK_URL, {
username: "Feedback",
avatar_url: "https://i.kym-cdn.com/entries/icons/facebook/000/043/403/cover3.jpg",
embeds: [
{
color: 3447003,
title: getFeedbackOption(type).label,
description: description,
},
],
})
.catch((error) => {
throw new Error(error);
});
return { status: "ok" };
});

View File

@@ -0,0 +1,3 @@
export default eventHandler(() => {
return { nitro: "works" };
});

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import DefaultTheme from "vitepress/theme";
import Sidebar from "./components/SidebarCard.vue";
import Announcement from "./components/Announcement.vue";
const { Layout } = DefaultTheme;
</script>
<template>
<Layout>
<template #sidebar-nav-after>
<Sidebar />
</template>
<template #home-hero-prelink>
<Announcement />
</template>
<Content />
</Layout>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useData } from "vitepress";
const { frontmatter } = useData();
</script>
<template>
<a
v-if="frontmatter.hero.prelink"
:href="frontmatter.hero.prelink.link"
target="_blank"
class="inline-flex items-center rounded-lg bg-[var(--vp-c-default-soft)] px-4 py-1 text-sm font-semibold mb-3">
{{ frontmatter.hero.prelink.title }}
</a>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
icon: string;
}>();
</script>
<template>
<div class="flex items-center mb-[8px] g-[12px]">
<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,199 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRoute } from "vitepress";
import type { FeedbackType } from "../../types/Feedback";
const loading = ref<boolean>(false);
const error = ref<unknown>(null);
const success = ref<boolean>(false);
const { path } = useRoute();
const feedback = reactive<FeedbackType>({ message: "", contact: "" });
const feedbackOptions = [
{ label: "🐞 Bug", value: "bug" },
{
label: "♻️ Suggestion",
value: "suggestion",
},
{ label: "📂 Other", value: "other" },
{
label: "❤️ Appreciation",
value: "appreciate",
},
];
function getFeedbackOption(value: string) {
return feedbackOptions.find((option) => option.value === value);
}
async function handleSubmit(type?: FeedbackType["type"]) {
if (type) feedback.type = type;
loading.value = true;
const body: FeedbackType = {
message: feedback.message,
type: feedback.type,
contact: feedback.contact,
page: path,
};
try {
const response = await fetch("https://feedback.tasky.workers.dev", {
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 === "success") {
success.value = true;
}
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="wrapper">
<Transition name="fade" mode="out-in">
<div v-if="!feedback.type" class="step">
<div>
<div>
<p class="heading">Feedback</p>
</div>
</div>
<div class="button-container">
<button v-for="item in feedbackOptions" :key="item.value" class="btn"
@click="handleSubmit(item.value as FeedbackType['type'])">
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div v-else-if="feedback.type && !success" class="step">
<div>
<p class="desc">The wiki is... {{ path }}</p>
<div>
<span>{{ getFeedbackOption(feedback.type)?.label }}</span>
<button style="margin-left: 0.5rem" class="btn" @click="feedback.type = undefined">
<span class="i-carbon-close-large">close</span>
</button>
</div>
</div>
<textarea v-model="feedback.message" autofocus class="input" />
<p class="desc">Contacts, so we can get back to you. (Optional)</p>
<textarea v-model="feedback.contact" class="contact-input" />
<button type="submit" class="btn btn-primary" :disabled="feedback.message.length > 10" @click="handleSubmit()">
Submit
</button>
</div>
<div v-else class="step">
<p class="heading">Thanks for your feedback!</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.step>*+* {
margin-top: 1rem;
}
.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;
}
.button-container {
display: grid;
grid-gap: 0.5rem;
}
.wrapper {
margin: 2rem 0;
padding: 1.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-alt);
}
.input {
width: 100%;
height: 100px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.375rem 0.75rem;
}
.contact-input {
height: 50px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.375rem 0.75rem;
}
.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 class="input-label" v-if="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,78 @@
<script setup lang="ts">
import { ref } from "vue";
import Feedback from "./Feedback.vue";
const showModal = ref(false);
</script>
<template>
<button class="p-[4px 8px] text-xl i-carbon:user-favorite-alt-filled" @click="showModal = true" />
<Teleport to="body">
<Transition name="modal">
<div v-show="showModal" class="modal-mask">
<div class="modal-container">
<Feedback />
<div class="model-footer">
<button class="modal-button" @click="showModal = false">Close</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-mask {
position: fixed;
z-index: 200;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.3s ease;
}
.modal-container {
width: 300px;
margin: auto;
padding: 20px 30px;
background-color: var(--vp-c-bg);
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.3s ease;
}
.model-footer {
margin-top: 8px;
text-align: right;
}
.modal-button {
padding: 4px 8px;
border-radius: 4px;
border-color: var(--vp-button-alt-border);
color: var(--vp-button-alt-text);
background-color: var(--vp-button-alt-bg);
}
.modal-button:hover {
border-color: var(--vp-button-alt-hover-border);
color: var(--vp-button-alt-hover-text);
background-color: var(--vp-button-alt-hover-bg);
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
transform: scale(1.1);
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import Field from "./CardField.vue";
import Modal from "./Modal.vue";
import InputField from "./InputField.vue";
import ToggleStarred from "./ToggleStarred.vue";
</script>
<template>
<div class="card">
<div class="card-header">
<div class="card-title">Emoji Legend</div>
</div>
<Field icon="i-twemoji-star">Recommendations</Field>
<Field icon="i-twemoji-globe-with-meridians">Indexes</Field>
<Field icon="i-twemoji-repeat-button">Storage Links</Field>
<div class="card-header">
<div class="card-title">Options</div>
</div>
<InputField id="feedback" label="Feedback">
<template #display>
<Modal />
</template>
</InputField>
<InputField id="toggle-starred" label="Toggle Starred">
<template #display>
<ToggleStarred />
</template>
</InputField>
</div>
</template>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.card-title {
font-weight: 700;
color: var(--vp-c-text-1);
line-height: 32px;
font-size: 16px;
}
.card {
background: var(--vp-c-bg);
padding: 12px 24px 24px;
border-radius: 12px;
position: relative;
z-index: 0;
border: 1px solid transparent;
transition: border-color 0.4s ease-in-out;
}
.card:hover {
border-color: var(--vp-c-brand-1);
}
</style>

View File

@@ -0,0 +1,52 @@
<script setup>
import { ref } from "vue";
import { Switch } from "@headlessui/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,53 @@
<script setup lang="ts">
import { inject } from "vue";
import { useData } from "vitepress";
import VPIconMoon from "vitepress/dist/client/theme-default/components/icons/VPIconMoon.vue";
import VPIconSun from "vitepress/dist/client/theme-default/components/icons/VPIconSun.vue";
const { isDark } = useData();
const toggleAppearance = inject("toggle-appearance", () => {
isDark.value = !isDark.value;
});
</script>
<template>
<button
type="button"
role="switch"
title="Toggle dark mode"
class="VPSwitchAppearance"
:aria-checked="isDark"
@click="toggleAppearance">
<ClientOnly>
<Transition name="fade" mode="out-in">
<VPIconSun v-if="!isDark" class="sun" />
<VPIconMoon v-else class="moon" />
</Transition>
</ClientOnly>
</button>
</template>
<style scoped>
.VPSwitchAppearance {
display: flex;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
color: var(--vp-c-text-2);
transition: color 0.5s;
&:hover {
color: var(--vp-c-text-1);
transition: color 0.25s;
}
& > :deep(svg) {
width: 20px;
height: 20px;
fill: currentColor;
}
}
</style>

View File

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

13
.vitepress/theme/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { type Theme, inBrowser } from "vitepress";
import DefaultTheme from "vitepress/theme";
import Layout from "./Layout.vue";
import "./style.css";
import "uno.css";
if (inBrowser) import("./pwa");
export default {
extends: DefaultTheme,
Layout: Layout,
enhanceApp({ app, router, siteData }) {},
} satisfies Theme;

3
.vitepress/theme/pwa.ts Normal file
View File

@@ -0,0 +1,3 @@
import { registerSW } from "virtual:pwa-register";
registerSW({ immediate: true });

145
.vitepress/theme/style.css Normal file
View File

@@ -0,0 +1,145 @@
:root {
/* Colors: Brand */
--vp-c-brand-1: #7bc5e4;
--vp-c-brand-2: #c4e2f2;
--vp-c-brand-3: #4882a7;
--vp-c-brand-soft: #a4d5ec;
/* Colors: Button */
--vp-button-brand-bg: var(--vp-c-brand-1);
--vp-button-brand-border: var(--vp-c-brand-soft);
--vp-button-brand-text: var(--vp-c-white);
--vp-button-brand-hover-border: var(--vp-c-brand-soft);
--vp-button-brand-hover-text: var(--vp-c-white);
--vp-button-brand-hover-bg: var(--vp-c-brand-soft);
--vp-button-brand-active-border: var(--vp-c-brand-soft);
--vp-button-brand-active-text: var(--vp-c-white);
--vp-button-brand-active-bg: var(--vp-button-brand-bg);
--vp-button-alt-bg: #484848;
--vp-button-alt-text: #f0eeee;
--vp-button-alt-hover-bg: #484848;
--vp-button-alt-hover-text: #f0eeee;
--vp-c-bg-elv: rgba(255, 255, 255, 0.7);
--vp-c-bg-mark: rgb(232, 232, 232);
/* Colors: Custom Block */
--vp-custom-block-info-bg: rgba(171, 210, 244, 0.05);
--vp-custom-block-info-border: #60c4fa;
--vp-custom-block-info-text: rgb(39, 115, 145);
--vp-custom-block-info-text-deep: rgb(18, 121, 162);
--vp-custom-block-tip-bg: rgba(137, 202, 176, 0.05);
--vp-custom-block-tip-border: rgba(34, 197, 94, 1);
--vp-custom-block-tip-text: rgb(10, 128, 90);
--vp-custom-block-tip-text-deep: rgb(11, 133, 94);
--vp-custom-block-warning-bg: rgba(250, 204, 21, 0.05);
--vp-custom-block-warning-border: rgba(245, 158, 11, 1);
--vp-custom-block-warning-text: rgb(166, 114, 35);
--vp-custom-block-warning-text-deep: rgb(199, 109, 6);
--vp-custom-block-danger-bg: rgba(220, 38, 38, 0.05);
--vp-custom-block-danger-border: rgba(248, 113, 113, 1);
--vp-custom-block-danger-text: rgb(196, 46, 46);
--vp-custom-block-danger-text-deep: rgba(220, 38, 38, 1);
}
.dark {
/* Colors: Background */
--vp-c-bg: rgb(26, 26, 26);
--vp-c-bg-alt: rgb(23, 23, 23);
--vp-c-bg-elv: rgba(23, 23, 23, 0.8);
/* Colors: Custom Block */
--vp-custom-block-info-bg: rgba(84, 110, 155, 0.1);
--vp-custom-block-info-border: #3686b1;
--vp-custom-block-info-text: #52b0e3;
--vp-custom-block-info-text-deep: #00b7ff;
--vp-custom-block-tip-bg: rgba(51, 130, 118, 0.1);
--vp-custom-block-tip-border: rgba(4, 120, 87, 1);
--vp-custom-block-tip-text: rgb(25, 190, 129);
--vp-custom-block-tip-text-deep: rgba(52, 211, 153, 1);
--vp-custom-block-warning-bg: rgba(253, 224, 71, 0.1);
--vp-custom-block-warning-border: rgba(202, 138, 4, 1);
--vp-custom-block-warning-text: rgba(234, 179, 8, 1);
--vp-custom-block-warning-text-deep: rgba(250, 204, 21, 1);
--vp-custom-block-danger-bg: rgba(239, 68, 68, 0.1);
--vp-custom-block-danger-border: rgba(127, 29, 29, 1);
--vp-custom-block-danger-text: rgba(248, 113, 113, 1);
--vp-custom-block-danger-text-deep: rgba(248, 113, 113, 1);
}
.vp-doc a {
color: var(--vp-c-brand-1);
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-style: solid;
text-decoration-color: transparent;
-webkit-text-decoration-color: transparent;
transition: text-decoration-color 0.25s;
}
.vp-doc a:hover {
color: var(--vp-c-brand-1);
text-decoration-color: var(--vp-c-brand-1);
-webkit-text-decoration-color: var(--vp-c-brand-1);
}
.vp-doc .custom-block a {
text-decoration: underline;
text-underline-offset: 4px;
text-decoration-style: solid;
}
::selection {
background-color: var(--vp-button-brand-bg);
}
.VPFooter a {
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 5px;
transition: 0.3s;
}
.VPFooter a:hover {
color: var(--vp-c-text-1);
text-decoration-line: underline;
text-decoration-style: dashed;
text-underline-offset: 5px;
}
/* Custom scrollbar */
.VPSidebar::-webkit-scrollbar {
block-size: 4px;
border-end-end-radius: 14px;
border-start-end-radius: 14px;
inline-size: 4px;
}
/**
* Component: Home
*/
:root {
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: -webkit-linear-gradient(120deg, #c4b5fd 30%, #7bc5e4);
--vp-home-hero-image-background-image: linear-gradient(-45deg, #c4b5fd 50%, #47caff 50%);
--vp-home-hero-image-filter: blur(44px);
}
@media (min-width: 640px) {
:root {
--vp-home-hero-image-filter: blur(56px);
}
}
@media (min-width: 960px) {
:root {
--vp-home-hero-image-filter: blur(68px);
}
}
.base64 {
min-width: 100%;
width: 0px;
white-space: pre-wrap;
}

View File

@@ -0,0 +1,10 @@
import z from "zod";
export const FeedbackSchema = z.object({
message: z.string().min(5).max(1000),
type: z.enum(["bug", "suggestion", "appreciate", "other"]),
contact: z.string().min(5).max(20).optional(),
page: z.string().min(3).max(10),
});
export type FeedbackType = z.infer<typeof FeedbackSchema>;

4
.vitepress/vue-shim.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.vue" {
const component: import("vue").Component;
export default component;
}