mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(i18n): 21-language pipeline, landing/docs/API wiring, landing+API translations
Shared Claude Code translation pipeline (scripts/i18n, no API key) plus Astro/VitePress/Scalar i18n wiring. Landing and API reference translated into all 20 languages; docs i18n wiring + English source anchors. The translated docs markdown (apps/docs/<locale>/**, 3,620 files) follows in a companion PR because it exceeds GitHub's per-PR CI file limit.
This commit is contained in:
+198
-111
@@ -2,6 +2,23 @@ import { defineConfig } from "vitepress";
|
||||
import llmstxt from "vitepress-plugin-llms";
|
||||
import { pagefindPlugin } from "vitepress-plugin-pagefind";
|
||||
import pkg from "../../../package.json";
|
||||
import { SUPPORTED_LOCALES } from "../../../packages/shared/src/i18n/index.ts";
|
||||
|
||||
const NON_EN = SUPPORTED_LOCALES.filter((l) => l.code !== "en");
|
||||
const HOSTNAME = "https://docs.snapotter.com";
|
||||
|
||||
// Prefix every `link` in a sidebar/nav tree with /<locale>.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: VitePress nav/sidebar item trees are recursively typed.
|
||||
function prefixLinks(items: any[], locale: string): any[] {
|
||||
return (items ?? []).map((it) => {
|
||||
const next = { ...it };
|
||||
if (typeof next.link === "string" && next.link.startsWith("/")) {
|
||||
next.link = `/${locale}${next.link}`;
|
||||
}
|
||||
if (Array.isArray(next.items)) next.items = prefixLinks(next.items, locale);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
title: "SnapOtter",
|
||||
@@ -17,7 +34,16 @@ export default defineConfig({
|
||||
|
||||
head: [
|
||||
["meta", { name: "theme-color", content: "#E07832" }],
|
||||
["link", { rel: "preload", href: "/fonts/bricolage-grotesque-var.woff2", as: "font", type: "font/woff2", crossorigin: "" }],
|
||||
[
|
||||
"link",
|
||||
{
|
||||
rel: "preload",
|
||||
href: "/fonts/bricolage-grotesque-var.woff2",
|
||||
as: "font",
|
||||
type: "font/woff2",
|
||||
crossorigin: "",
|
||||
},
|
||||
],
|
||||
["link", { rel: "icon", type: "image/png", sizes: "48x48", href: "/favicon.png" }],
|
||||
["link", { rel: "icon", type: "image/x-icon", href: "/favicon.ico" }],
|
||||
["link", { rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png" }],
|
||||
@@ -28,7 +54,6 @@ export default defineConfig({
|
||||
["meta", { property: "og:image:width", content: "1280" }],
|
||||
["meta", { property: "og:image:height", content: "640" }],
|
||||
["meta", { property: "og:image:alt", content: "SnapOtter - Self-Hosted File Processing" }],
|
||||
["meta", { property: "og:locale", content: "en_US" }],
|
||||
["meta", { name: "twitter:card", content: "summary_large_image" }],
|
||||
["meta", { name: "twitter:site", content: "@SnapOtterHQ" }],
|
||||
["meta", { name: "twitter:image", content: "https://docs.snapotter.com/og-image.png" }],
|
||||
@@ -36,8 +61,25 @@ export default defineConfig({
|
||||
|
||||
transformHead({ pageData }) {
|
||||
const head: Array<[string, Record<string, string>]> = [];
|
||||
const canonicalUrl = `https://docs.snapotter.com/${pageData.relativePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "")}`;
|
||||
head.push(["meta", { property: "og:url", content: canonicalUrl }]);
|
||||
const rel = pageData.relativePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "");
|
||||
const codes = NON_EN.map((l) => l.code);
|
||||
const firstSeg = rel.split("/")[0];
|
||||
const isLocale = codes.includes(firstSeg);
|
||||
const enRel = isLocale ? rel.split("/").slice(1).join("/") : rel;
|
||||
const current = isLocale ? firstSeg : "en";
|
||||
|
||||
const urlFor = (code: string) =>
|
||||
code === "en" ? `${HOSTNAME}/${enRel}` : `${HOSTNAME}/${code}/${enRel}`;
|
||||
|
||||
head.push(["link", { rel: "canonical", href: urlFor(current) }]);
|
||||
head.push(["link", { rel: "alternate", hreflang: "x-default", href: urlFor("en") }]);
|
||||
head.push(["link", { rel: "alternate", hreflang: "en", href: urlFor("en") }]);
|
||||
for (const l of NON_EN) {
|
||||
head.push(["link", { rel: "alternate", hreflang: l.code, href: urlFor(l.code) }]);
|
||||
}
|
||||
const ogLocale = current === "en" ? "en_US" : current.replace("-", "_");
|
||||
head.push(["meta", { property: "og:locale", content: ogLocale }]);
|
||||
head.push(["meta", { property: "og:url", content: urlFor(current) }]);
|
||||
head.push(["meta", { property: "og:title", content: pageData.title }]);
|
||||
if (pageData.description) {
|
||||
head.push(["meta", { property: "og:description", content: pageData.description }]);
|
||||
@@ -69,6 +111,21 @@ export default defineConfig({
|
||||
heading: "{{searchResult}} results",
|
||||
// Show a few sub-section matches per page so deep-linked headings surface.
|
||||
pageResultCount: 3,
|
||||
// Per-locale placeholders. No forceLanguage: Pagefind auto-detects per
|
||||
// <html lang> (VitePress sets it per locale) and langReload loads the
|
||||
// right index on switch. Strings stay English until Plan 05 localizes them.
|
||||
locales: Object.fromEntries(
|
||||
NON_EN.map((l) => [
|
||||
l.code,
|
||||
{
|
||||
btnPlaceholder: "Search",
|
||||
placeholder: "Search tools, guides, and the API…",
|
||||
emptyText: "No matches found. Try a different term or check the spelling.",
|
||||
heading: "{{searchResult}} results",
|
||||
pageResultCount: 3,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
llmstxt({
|
||||
domain: "https://docs.snapotter.com",
|
||||
@@ -107,7 +164,37 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
themeConfig: buildBaseTheme(),
|
||||
|
||||
locales: {
|
||||
root: { label: "English", lang: "en" },
|
||||
...Object.fromEntries(
|
||||
NON_EN.map((l) => [
|
||||
l.code,
|
||||
{
|
||||
label: l.nativeName,
|
||||
lang: l.code,
|
||||
dir: l.dir,
|
||||
link: `/${l.code}/`,
|
||||
themeConfig: {
|
||||
nav: prefixLinks(buildBaseTheme().nav, l.code),
|
||||
sidebar: prefixLinks(buildBaseTheme().sidebar, l.code),
|
||||
editLink: {
|
||||
pattern: "https://github.com/snapotter-hq/snapotter/edit/main/apps/docs/:path",
|
||||
text: "Edit this page on GitHub",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Function declaration (hoisted) so defineConfig above can call it while the
|
||||
// nav/sidebar/footer tree stays defined once. Root uses it verbatim; each locale
|
||||
// gets a /<locale>-prefixed copy of nav + sidebar via prefixLinks().
|
||||
function buildBaseTheme() {
|
||||
return {
|
||||
logo: "/logo.png",
|
||||
|
||||
nav: [
|
||||
@@ -154,110 +241,110 @@ export default defineConfig({
|
||||
text: "Image",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Essentials",
|
||||
items: [
|
||||
{ text: "Resize", link: "/tools/image/resize" },
|
||||
{ text: "Crop", link: "/tools/image/crop" },
|
||||
{ text: "Rotate & Flip", link: "/tools/image/rotate" },
|
||||
{ text: "Convert", link: "/tools/image/convert" },
|
||||
{ text: "Compress", link: "/tools/image/compress" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Optimization",
|
||||
items: [
|
||||
{ text: "Optimize for Web", link: "/tools/image/optimize-for-web" },
|
||||
{ text: "Remove Metadata", link: "/tools/image/strip-metadata" },
|
||||
{ text: "Edit Metadata", link: "/tools/image/edit-metadata" },
|
||||
{ text: "Bulk Rename", link: "/tools/image/bulk-rename" },
|
||||
{ text: "Image to PDF", link: "/tools/image/image-to-pdf" },
|
||||
{ text: "Favicon Generator", link: "/tools/image/favicon" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Adjustments",
|
||||
items: [
|
||||
{ text: "Adjust Colors", link: "/tools/image/adjust-colors" },
|
||||
{ text: "Sharpening", link: "/tools/image/sharpening" },
|
||||
{ text: "Replace & Invert Color", link: "/tools/image/replace-color" },
|
||||
{ text: "Color Blindness Simulation", link: "/tools/image/color-blindness" },
|
||||
{ text: "Duotone", link: "/tools/image/duotone" },
|
||||
{ text: "Pixelate", link: "/tools/image/pixelate" },
|
||||
{ text: "Vignette", link: "/tools/image/vignette" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Watermark & Overlay",
|
||||
items: [
|
||||
{ text: "Text Watermark", link: "/tools/image/watermark-text" },
|
||||
{ text: "Image Watermark", link: "/tools/image/watermark-image" },
|
||||
{ text: "Text Overlay", link: "/tools/image/text-overlay" },
|
||||
{ text: "Image Composition", link: "/tools/image/compose" },
|
||||
{ text: "Meme Generator", link: "/tools/image/meme-generator" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Utilities",
|
||||
items: [
|
||||
{ text: "Image Info", link: "/tools/image/info" },
|
||||
{ text: "Image Compare", link: "/tools/image/compare" },
|
||||
{ text: "Find Duplicates", link: "/tools/image/find-duplicates" },
|
||||
{ text: "Color Palette", link: "/tools/image/color-palette" },
|
||||
{ text: "QR Code Generator", link: "/tools/image/qr-generate" },
|
||||
{ text: "HTML to Image", link: "/tools/image/html-to-image" },
|
||||
{ text: "Barcode Reader", link: "/tools/image/barcode-read" },
|
||||
{ text: "Image to Base64", link: "/tools/image/image-to-base64" },
|
||||
{ text: "Histogram", link: "/tools/image/histogram" },
|
||||
{ text: "LQIP Placeholder", link: "/tools/image/lqip-placeholder" },
|
||||
{ text: "Barcode Generator", link: "/tools/image/barcode-generate" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Layout",
|
||||
items: [
|
||||
{ text: "Collage / Grid", link: "/tools/image/collage" },
|
||||
{ text: "Stitch / Combine", link: "/tools/image/stitch" },
|
||||
{ text: "Image Splitting", link: "/tools/image/split" },
|
||||
{ text: "Border & Frame", link: "/tools/image/border" },
|
||||
{ text: "Beautify Screenshot", link: "/tools/image/beautify" },
|
||||
{ text: "Circle Crop", link: "/tools/image/circle-crop" },
|
||||
{ text: "Image Pad", link: "/tools/image/image-pad" },
|
||||
{ text: "Sprite Sheet", link: "/tools/image/sprite-sheet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Format",
|
||||
items: [
|
||||
{ text: "SVG to Raster", link: "/tools/image/svg-to-raster" },
|
||||
{ text: "Image to SVG", link: "/tools/image/vectorize" },
|
||||
{ text: "GIF Tools", link: "/tools/image/gif-tools" },
|
||||
{ text: "GIF/WebP Converter", link: "/tools/image/gif-webp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "AI Tools",
|
||||
items: [
|
||||
{ text: "Remove Background", link: "/tools/image/remove-background" },
|
||||
{ text: "Image Upscaling", link: "/tools/image/upscale" },
|
||||
{ text: "Object Eraser", link: "/tools/image/erase-object" },
|
||||
{ text: "OCR / Text Extraction", link: "/tools/image/ocr" },
|
||||
{ text: "Face / PII Blur", link: "/tools/image/blur-faces" },
|
||||
{ text: "Smart Crop", link: "/tools/image/smart-crop" },
|
||||
{ text: "Image Enhancement", link: "/tools/image/image-enhancement" },
|
||||
{ text: "Face Enhancement", link: "/tools/image/enhance-faces" },
|
||||
{ text: "AI Colorization", link: "/tools/image/colorize" },
|
||||
{ text: "Noise Removal", link: "/tools/image/noise-removal" },
|
||||
{ text: "Red Eye Removal", link: "/tools/image/red-eye-removal" },
|
||||
{ text: "Photo Restoration", link: "/tools/image/restore-photo" },
|
||||
{ text: "Passport Photo", link: "/tools/image/passport-photo" },
|
||||
{ text: "Content-Aware Resize", link: "/tools/image/content-aware-resize" },
|
||||
{ text: "AI Canvas Expand", link: "/tools/image/ai-canvas-expand" },
|
||||
{ text: "PNG Transparency Fixer", link: "/tools/image/transparency-fixer" },
|
||||
{ text: "Background Replace", link: "/tools/image/background-replace" },
|
||||
{ text: "Blur Background", link: "/tools/image/blur-background" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Essentials",
|
||||
items: [
|
||||
{ text: "Resize", link: "/tools/image/resize" },
|
||||
{ text: "Crop", link: "/tools/image/crop" },
|
||||
{ text: "Rotate & Flip", link: "/tools/image/rotate" },
|
||||
{ text: "Convert", link: "/tools/image/convert" },
|
||||
{ text: "Compress", link: "/tools/image/compress" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Optimization",
|
||||
items: [
|
||||
{ text: "Optimize for Web", link: "/tools/image/optimize-for-web" },
|
||||
{ text: "Remove Metadata", link: "/tools/image/strip-metadata" },
|
||||
{ text: "Edit Metadata", link: "/tools/image/edit-metadata" },
|
||||
{ text: "Bulk Rename", link: "/tools/image/bulk-rename" },
|
||||
{ text: "Image to PDF", link: "/tools/image/image-to-pdf" },
|
||||
{ text: "Favicon Generator", link: "/tools/image/favicon" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Adjustments",
|
||||
items: [
|
||||
{ text: "Adjust Colors", link: "/tools/image/adjust-colors" },
|
||||
{ text: "Sharpening", link: "/tools/image/sharpening" },
|
||||
{ text: "Replace & Invert Color", link: "/tools/image/replace-color" },
|
||||
{ text: "Color Blindness Simulation", link: "/tools/image/color-blindness" },
|
||||
{ text: "Duotone", link: "/tools/image/duotone" },
|
||||
{ text: "Pixelate", link: "/tools/image/pixelate" },
|
||||
{ text: "Vignette", link: "/tools/image/vignette" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Watermark & Overlay",
|
||||
items: [
|
||||
{ text: "Text Watermark", link: "/tools/image/watermark-text" },
|
||||
{ text: "Image Watermark", link: "/tools/image/watermark-image" },
|
||||
{ text: "Text Overlay", link: "/tools/image/text-overlay" },
|
||||
{ text: "Image Composition", link: "/tools/image/compose" },
|
||||
{ text: "Meme Generator", link: "/tools/image/meme-generator" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Utilities",
|
||||
items: [
|
||||
{ text: "Image Info", link: "/tools/image/info" },
|
||||
{ text: "Image Compare", link: "/tools/image/compare" },
|
||||
{ text: "Find Duplicates", link: "/tools/image/find-duplicates" },
|
||||
{ text: "Color Palette", link: "/tools/image/color-palette" },
|
||||
{ text: "QR Code Generator", link: "/tools/image/qr-generate" },
|
||||
{ text: "HTML to Image", link: "/tools/image/html-to-image" },
|
||||
{ text: "Barcode Reader", link: "/tools/image/barcode-read" },
|
||||
{ text: "Image to Base64", link: "/tools/image/image-to-base64" },
|
||||
{ text: "Histogram", link: "/tools/image/histogram" },
|
||||
{ text: "LQIP Placeholder", link: "/tools/image/lqip-placeholder" },
|
||||
{ text: "Barcode Generator", link: "/tools/image/barcode-generate" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Layout",
|
||||
items: [
|
||||
{ text: "Collage / Grid", link: "/tools/image/collage" },
|
||||
{ text: "Stitch / Combine", link: "/tools/image/stitch" },
|
||||
{ text: "Image Splitting", link: "/tools/image/split" },
|
||||
{ text: "Border & Frame", link: "/tools/image/border" },
|
||||
{ text: "Beautify Screenshot", link: "/tools/image/beautify" },
|
||||
{ text: "Circle Crop", link: "/tools/image/circle-crop" },
|
||||
{ text: "Image Pad", link: "/tools/image/image-pad" },
|
||||
{ text: "Sprite Sheet", link: "/tools/image/sprite-sheet" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Format",
|
||||
items: [
|
||||
{ text: "SVG to Raster", link: "/tools/image/svg-to-raster" },
|
||||
{ text: "Image to SVG", link: "/tools/image/vectorize" },
|
||||
{ text: "GIF Tools", link: "/tools/image/gif-tools" },
|
||||
{ text: "GIF/WebP Converter", link: "/tools/image/gif-webp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "AI Tools",
|
||||
items: [
|
||||
{ text: "Remove Background", link: "/tools/image/remove-background" },
|
||||
{ text: "Image Upscaling", link: "/tools/image/upscale" },
|
||||
{ text: "Object Eraser", link: "/tools/image/erase-object" },
|
||||
{ text: "OCR / Text Extraction", link: "/tools/image/ocr" },
|
||||
{ text: "Face / PII Blur", link: "/tools/image/blur-faces" },
|
||||
{ text: "Smart Crop", link: "/tools/image/smart-crop" },
|
||||
{ text: "Image Enhancement", link: "/tools/image/image-enhancement" },
|
||||
{ text: "Face Enhancement", link: "/tools/image/enhance-faces" },
|
||||
{ text: "AI Colorization", link: "/tools/image/colorize" },
|
||||
{ text: "Noise Removal", link: "/tools/image/noise-removal" },
|
||||
{ text: "Red Eye Removal", link: "/tools/image/red-eye-removal" },
|
||||
{ text: "Photo Restoration", link: "/tools/image/restore-photo" },
|
||||
{ text: "Passport Photo", link: "/tools/image/passport-photo" },
|
||||
{ text: "Content-Aware Resize", link: "/tools/image/content-aware-resize" },
|
||||
{ text: "AI Canvas Expand", link: "/tools/image/ai-canvas-expand" },
|
||||
{ text: "PNG Transparency Fixer", link: "/tools/image/transparency-fixer" },
|
||||
{ text: "Background Replace", link: "/tools/image/background-replace" },
|
||||
{ text: "Blur Background", link: "/tools/image/blur-background" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -406,5 +493,5 @@ export default defineConfig({
|
||||
pattern: "https://github.com/snapotter-hq/snapotter/edit/main/apps/docs/:path",
|
||||
text: "Edit this page on GitHub",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// apps/docs/.vitepress/i18n/ui.mjs
|
||||
import { SUPPORTED_LOCALES } from "../../../../packages/shared/src/i18n/index.ts";
|
||||
|
||||
// Source strings (English). Other locales override keys as translations land;
|
||||
// missing keys fall back to English via t().
|
||||
const EN = {
|
||||
// DocsHome.vue
|
||||
"home.title": "SnapOtter Documentation",
|
||||
"home.copy": "Copy",
|
||||
"home.copied": "Copied!",
|
||||
"home.selfHosting": "Self-hosting",
|
||||
"home.enterprise": "Enterprise",
|
||||
"home.startSelfHosting": "Start self-hosting →",
|
||||
"home.evaluate": "See enterprise features →",
|
||||
"home.modalities": "200+ tools across 5 modalities",
|
||||
"home.browseByType": "browse the full reference by type",
|
||||
// FundButton.vue
|
||||
"fund.label": "Fund Development",
|
||||
// GitHubStars.vue
|
||||
"github.star": "Star",
|
||||
// Machine-translation banner
|
||||
"banner.text": "This page was machine-translated. Spotted a mistake?",
|
||||
"banner.cta": "Help improve it.",
|
||||
// not-found (Layout.vue)
|
||||
"notFound.heading": "Hello from the otter side!",
|
||||
"notFound.text": "This page swam away. Let's get you back on track.",
|
||||
"notFound.link": "Back to docs",
|
||||
};
|
||||
|
||||
// Per-locale overrides. Generated/refined by the pipeline in Plan 05; ships with
|
||||
// English only, so t() returns English for every non-en locale until then.
|
||||
/** @type {Record<string, Record<string, string>>} */
|
||||
const OVERRIDES = {};
|
||||
|
||||
/**
|
||||
* @param {string} locale
|
||||
* @param {string} key
|
||||
* @returns {string}
|
||||
*/
|
||||
export function t(locale, key) {
|
||||
return OVERRIDES[locale]?.[key] ?? EN[key] ?? key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VitePress lang to a catalog locale key (en for English variants).
|
||||
* @param {string} lang
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeLocale(lang) {
|
||||
const code = (lang || "en").trim();
|
||||
if (code === "en" || code.startsWith("en-")) return "en";
|
||||
return SUPPORTED_LOCALES.some((l) => l.code === code) ? code : "en";
|
||||
}
|
||||
|
||||
export { SUPPORTED_LOCALES };
|
||||
@@ -1,5 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
const command = "docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest";
|
||||
import { useData } from "vitepress";
|
||||
import { computed, ref } from "vue";
|
||||
import { normalizeLocale, t } from "../i18n/ui.mjs";
|
||||
|
||||
const { lang } = useData();
|
||||
const locale = computed(() => normalizeLocale(lang.value));
|
||||
const tt = (key: string) => t(locale.value, key);
|
||||
|
||||
const command =
|
||||
"docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/snapotter:latest";
|
||||
|
||||
const selfLinks = [
|
||||
{ label: "Quick start", href: "/guide/getting-started#quick-start" },
|
||||
@@ -30,19 +39,20 @@ const shared = [
|
||||
{ label: "llms.txt", sub: "AI-friendly docs", href: "/llms.txt" },
|
||||
];
|
||||
|
||||
import { ref } from "vue";
|
||||
const copyLabel = ref("Copy");
|
||||
const copyLabel = ref(t(locale.value, "home.copy"));
|
||||
function copyCommand() {
|
||||
navigator.clipboard?.writeText(command);
|
||||
copyLabel.value = "Copied!";
|
||||
setTimeout(() => { copyLabel.value = "Copy"; }, 1500);
|
||||
copyLabel.value = t(locale.value, "home.copied");
|
||||
setTimeout(() => {
|
||||
copyLabel.value = t(locale.value, "home.copy");
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="so-home">
|
||||
<section class="hero">
|
||||
<h1 class="hero-title">SnapOtter Documentation</h1>
|
||||
<h1 class="hero-title">{{ tt("home.title") }}</h1>
|
||||
<p class="hero-sub">
|
||||
Install, operate, and build on your self-hosted file-processing infrastructure. Get running
|
||||
in one command:
|
||||
@@ -64,13 +74,13 @@ function copyCommand() {
|
||||
<span class="door-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/></svg>
|
||||
</span>
|
||||
<h2>Self-hosting</h2>
|
||||
<h2>{{ tt("home.selfHosting") }}</h2>
|
||||
</div>
|
||||
<p class="door-sub">Get SnapOtter running and keep it healthy.</p>
|
||||
<ul class="door-links">
|
||||
<li v-for="l in selfLinks" :key="l.href"><a :href="l.href">{{ l.label }}</a></li>
|
||||
</ul>
|
||||
<a class="door-cta" href="/guide/getting-started">Start self-hosting →</a>
|
||||
<a class="door-cta" href="/guide/getting-started">{{ tt("home.startSelfHosting") }}</a>
|
||||
</div>
|
||||
|
||||
<div class="door ent">
|
||||
@@ -78,18 +88,18 @@ function copyCommand() {
|
||||
<span class="door-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M5 21V7l8-4v18M19 21V11l-6-4"/></svg>
|
||||
</span>
|
||||
<h2>Enterprise</h2>
|
||||
<h2>{{ tt("home.enterprise") }}</h2>
|
||||
</div>
|
||||
<p class="door-sub">Evaluate, secure & govern your deployment.</p>
|
||||
<ul class="door-links">
|
||||
<li v-for="l in entLinks" :key="l.href"><a :href="l.href">{{ l.label }}</a></li>
|
||||
</ul>
|
||||
<a class="door-cta" href="https://snapotter.com/enterprise">See enterprise features →</a>
|
||||
<a class="door-cta" href="https://snapotter.com/enterprise">{{ tt("home.evaluate") }}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mod">
|
||||
<p class="mod-head"><strong>200+ tools across 5 modalities</strong> <span>browse the full reference by type</span></p>
|
||||
<p class="mod-head"><strong>{{ tt("home.modalities") }}</strong> <span>{{ tt("home.browseByType") }}</span></p>
|
||||
<div class="chips">
|
||||
<a v-for="m in modalities" :key="m.href" class="chip" :href="m.href">
|
||||
<span class="chip-label">{{ m.label }}</span>
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from "vitepress";
|
||||
import { computed } from "vue";
|
||||
import { normalizeLocale, t } from "../i18n/ui.mjs";
|
||||
|
||||
const { lang } = useData();
|
||||
const locale = computed(() => normalizeLocale(lang.value));
|
||||
const label = computed(() => t(locale.value, "fund.label"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
href="https://github.com/sponsors/snapotter-hq"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="fund-btn"
|
||||
title="Fund Development"
|
||||
:title="label"
|
||||
>
|
||||
<svg class="fund-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<span>Fund Development</span>
|
||||
<span>{{ label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useData } from "vitepress";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { normalizeLocale, t } from "../i18n/ui.mjs";
|
||||
|
||||
const { lang } = useData();
|
||||
const locale = computed(() => normalizeLocale(lang.value));
|
||||
const starLabel = computed(() => t(locale.value, "github.star"));
|
||||
|
||||
const stars = ref<string | null>(null);
|
||||
const repo = "https://github.com/snapotter-hq/snapotter";
|
||||
@@ -24,7 +30,7 @@ onMounted(async () => {
|
||||
<svg class="github-icon" width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span class="github-btn-label">Star</span>
|
||||
<span class="github-btn-label">{{ starLabel }}</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="stars !== null"
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
<script setup>
|
||||
import { useData } from "vitepress";
|
||||
import VPSwitchAppearance from "vitepress/dist/client/theme-default/components/VPSwitchAppearance.vue";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
import { computed } from "vue";
|
||||
import { normalizeLocale, t } from "../i18n/ui.mjs";
|
||||
import FundButton from "./FundButton.vue";
|
||||
import GitHubStars from "./GitHubStars.vue";
|
||||
import MachineTranslationBanner from "./MachineTranslationBanner.vue";
|
||||
|
||||
const { Layout } = DefaultTheme;
|
||||
const { lang } = useData();
|
||||
const locale = computed(() => normalizeLocale(lang.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #layout-top>
|
||||
<MachineTranslationBanner />
|
||||
</template>
|
||||
<template #nav-bar-content-after>
|
||||
<div class="nav-bar-right">
|
||||
<VPSwitchAppearance />
|
||||
@@ -19,9 +28,9 @@ const { Layout } = DefaultTheme;
|
||||
<template #not-found>
|
||||
<div class="not-found-page">
|
||||
<span class="not-found-otter">🦦</span>
|
||||
<h1 class="not-found-heading">Hello from the otter side!</h1>
|
||||
<p class="not-found-text">This page swam away. Let's get you back on track.</p>
|
||||
<a href="/" class="not-found-link">Back to docs</a>
|
||||
<h1 class="not-found-heading">{{ t(locale, "notFound.heading") }}</h1>
|
||||
<p class="not-found-text">{{ t(locale, "notFound.text") }}</p>
|
||||
<a href="/" class="not-found-link">{{ t(locale, "notFound.link") }}</a>
|
||||
</div>
|
||||
</template>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { useData } from "vitepress";
|
||||
import { computed, ref } from "vue";
|
||||
import { normalizeLocale, t } from "../i18n/ui.mjs";
|
||||
|
||||
const { lang } = useData();
|
||||
// Guard both existence AND callability: VitePress SSR defines a `localStorage`
|
||||
// global that is not a real Storage, so `getItem` may not be a function there.
|
||||
function readDismissed() {
|
||||
try {
|
||||
return (
|
||||
typeof localStorage?.getItem === "function" && localStorage.getItem("so-mt-banner") === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const dismissed = ref(readDismissed());
|
||||
const locale = computed(() => normalizeLocale(lang.value));
|
||||
const show = computed(() => locale.value !== "en" && !dismissed.value);
|
||||
const text = computed(() => t(locale.value, "banner.text"));
|
||||
const cta = computed(() => t(locale.value, "banner.cta"));
|
||||
|
||||
function dismiss() {
|
||||
dismissed.value = true;
|
||||
try {
|
||||
localStorage.setItem("so-mt-banner", "1");
|
||||
} catch {
|
||||
// ignore storage failures (private mode)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="mt-banner" role="note">
|
||||
<span>{{ text }}</span>
|
||||
<a href="https://github.com/snapotter-hq/snapotter/blob/main/apps/docs/guide/translations.md">{{ cta }}</a>
|
||||
<button type="button" class="mt-dismiss" aria-label="Dismiss" @click="dismiss">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mt-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
background: #FFF5ED;
|
||||
border-bottom: 1px solid #EBDDCC;
|
||||
color: #7A4A1E;
|
||||
}
|
||||
.mt-banner a { color: #A85518; font-weight: 600; }
|
||||
.mt-dismiss {
|
||||
margin-inline-start: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
:root.dark .mt-banner { background: #241A13; border-color: #3A2C20; color: #E0C6A8; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user