1
0
mirror of https://github.com/NickKaramoff/toot synced 2025-06-05 21:59:33 +02:00
This commit is contained in:
Nikita Karamov
2024-09-03 19:19:27 +02:00
commit 10a31d068e
36 changed files with 1058 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# Fresh build directory
_fresh/
# npm dependencies
node_modules/

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"denoland.vscode-deno"
]
}

17
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
"deno.enable": true,
"deno.lint": true,
"editor.defaultFormatter": "denoland.vscode-deno",
"[typescriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[typescript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[javascriptreact]": {
"editor.defaultFormatter": "denoland.vscode-deno"
},
"[javascript]": {
"editor.defaultFormatter": "denoland.vscode-deno"
}
}

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# Fresh project
Your new Fresh project is ready to go. You can follow the Fresh "Getting
Started" guide here: https://fresh.deno.dev/docs/getting-started
### Usage
Make sure to install Deno: https://deno.land/manual/getting_started/installation
Then start the project:
```
deno task start
```
This will watch the project directory and restart as necessary.

12
components/Button.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { JSX } from "preact";
import { IS_BROWSER } from "$fresh/runtime.ts";
export function Button(props: JSX.HTMLAttributes<HTMLButtonElement>) {
return (
<button
{...props}
disabled={!IS_BROWSER || props.disabled}
class="px-2 py-1 border-gray-500 border-2 rounded bg-white hover:bg-gray-200 transition-colors"
/>
);
}

27
deno.json Normal file
View File

@@ -0,0 +1,27 @@
{
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.1/",
"$lib/": "./lib/",
"$i18n/": "./i18n/",
"@http/response": "jsr:@http/response@^0.21.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.3.0",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.8.0",
"@std/dotenv": "jsr:@std/dotenv@^0.225.1",
"preact": "https://esm.sh/preact@10.23.2",
"preact/": "https://esm.sh/preact@10.23.2/",
"zod": "https://deno.land/x/zod@v3.23.8/mod.ts"
},
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }
}

8
dev.ts Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
import "@std/dotenv/load";
await dev(import.meta.url, "./main.ts", config);

7
fresh.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "$fresh/server.ts";
export default defineConfig({
server: {
port: 9979,
},
});

31
fresh.gen.ts Normal file
View File

@@ -0,0 +1,31 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $_middleware from "./routes/_middleware.ts";
import * as $api_detect_domain_ from "./routes/api/detect/[domain].ts";
import * as $api_instances from "./routes/api/instances.ts";
import * as $greet_name_ from "./routes/greet/[name].tsx";
import * as $index from "./routes/index.tsx";
import * as $Counter from "./islands/Counter.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/_middleware.ts": $_middleware,
"./routes/api/detect/[domain].ts": $api_detect_domain_,
"./routes/api/instances.ts": $api_instances,
"./routes/greet/[name].tsx": $greet_name_,
"./routes/index.tsx": $index,
},
islands: {
"./islands/Counter.tsx": $Counter,
},
baseUrl: import.meta.url,
} satisfies Manifest;
export default manifest;

78
i18n/engine.ts Normal file
View File

@@ -0,0 +1,78 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defaultLanguage, languages, strings } from "./translations";
export function useTranslations(language: string) {
return function t(
key: keyof (typeof strings)[typeof defaultLanguage],
): string {
return (
strings[language as keyof typeof strings][key] ||
strings[defaultLanguage][key] ||
""
);
};
}
export function findBestLanguage(): string {
const urlLanguage = new URLSearchParams(window.location.search).get("lang");
if (urlLanguage && urlLanguage in languages) {
return urlLanguage;
}
let browserLanguages = navigator.languages;
if (!navigator.languages) browserLanguages = [navigator.language];
for (const language of browserLanguages) {
const locale = new Intl.Locale(language);
const minimized = locale.minimize();
for (const candidate of [locale.baseName, minimized.baseName]) {
if (candidate in languages) {
return candidate;
}
}
}
return defaultLanguage;
}
export function applyTranslations(language: string) {
if (!(language in strings)) {
language = defaultLanguage;
}
const t = useTranslations(language);
for (const node of document.querySelectorAll("[data-translate]")) {
const dataset = (node as HTMLElement).dataset;
if (dataset.translateAttribute) {
node.setAttribute(dataset.translateAttribute, t(dataset.translate!));
continue;
}
let splitTranslated = t(dataset.translate!).split("{}");
if (splitTranslated.length === 1) {
node.innerHTML = t(dataset.translate!);
continue;
}
// XXX: this is needed for the strings where the placholder sits at the very
// beginning, which introduces phantom empty strings.
splitTranslated = splitTranslated.filter((string) => string !== "");
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
child.textContent = splitTranslated.shift() || "";
}
}
}
document.documentElement.lang = language;
document.documentElement.dir =
languages[language as keyof typeof languages].dir;
}

34
i18n/translations.ts Normal file
View File

@@ -0,0 +1,34 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
import de from "./translations/de.json" with {type: "json"};
import en from "./translations/en.json" with {type: "json"};
import es from "./translations/es.json" with {type: "json"};
import fr from "./translations/fr.json" with {type: "json"};
import nl from "./translations/nl.json" with {type: "json"};
import ru from "./translations/ru.json" with {type: "json"};
export const languages = {
en: { autonym: "English", dir: "ltr" },
de: { autonym: "Deutsch", dir: "ltr" },
es: { autonym: "Español", dir: "ltr" },
fr: { autonym: "Français", dir: "ltr" },
nl: { autonym: "Nederlands", dir: "ltr" },
ru: { autonym: "Русский", dir: "ltr" },
};
export const strings: Record<keyof typeof languages, Record<string, string>> = {
en,
de,
es,
fr,
nl,
ru,
} as const;
export const defaultLanguage: keyof typeof strings = "en";

23
i18n/translations/ar.json Normal file
View File

@@ -0,0 +1,23 @@
{
"privacyNotice": "إشعار الخصوصية",
"postText": "نص المنشور{}",
"fediverse": "الفديفرس",
"vercelPP": "سياسة الخصوصية لـ Vercel",
"description": "شير تو فيدي Share₂Fedi هو موقع مشاركة لـ {}. يمكنك من خلاله نشر محتوى على منصات متعددة تتبع نموذج الفديرالية من صفحة واحدة.",
"incl": "بما فيه",
"postTextPlaceholder": "فيمَ تفكّر؟",
"rememberInstance": "{} تذكر مثيل الخادم على هذا الجهاز",
"instance": "مثيل خادم الفديفرس{}",
"licence1": "شير تو فيدي Share₂Fedi هو برنامج حر: يمكنك إعادة توزيعه و/أو تعديله وفقًا لشروط الرخصة العامة GNU Affero، الإصدار 3، كما نَشرَتها مؤسسة البرمجيات الحرة.",
"previouslyUsed": "تم استخدامه مسبقًا: {}",
"metaDescription": "شير تو فيدي Share₂Fedi هي صفحة مشاركة لـ Mastodon و Misskey و Friendica وغيرها. اكتب نص منشورك وعنوان الرابط التشعبي لمثيل الخادم، ثم انقر على ”أنشر“!",
"nikita": "نيكيتا كاراموف",
"statusPage": "صفحة الحالة",
"publish": "أنشر",
"licence": "الرخصة",
"language": "اللغة: {}",
"supportedProjects": "المشاريع المدعومة:",
"privacy2": "عندما تنقر على زر ”أنشر“، سيتم توجيهك إلى مثيل خادم الفديفرس الذي حددته. قد يقوم بمعالجة و/أو تخزين بياناتك. يرجى الرجوع إلى سياسة الخصوصية للخادم المعني.",
"onGitHub": "على جت هب",
"credits": "تم تطوير Share₂Fedi وصيانته من طرف {}. الشيفرة المصدرية متوفرة على {}. مستضافة على {}. {}."
}

25
i18n/translations/de.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metaDescription": "Share₂Fedi ist eine Share-Seite für Mastodon, Misskey, Friendica und andere. Geben Sie Ihren Beitragstext und die Instanz-URL ein und klicken Sie auf „Veröffentlichen“!",
"language": "Sprache: {}",
"description": "Share₂Fedi ist eine instanzunabhängige Share-Seite für {}. Mit ihr können Sie von einer einzigen Seite aus auf verschiedenen föderierten Plattformen posten.",
"fediverse": "das Fediverse",
"supportedProjects": "Unterstützte Projekte:",
"incl": "inkl.",
"credits": "Share₂Fedi wird von {} entwickelt und gepflegt. Der Quellcode ist {}. Gehostet mit {}. {}.",
"nikita": "Nikita Karamov",
"onGitHub": "auf GitHub",
"statusPage": "Statusseite",
"licence": "Lizenz",
"licence1": "Share₂Fedi ist freie Software: Sie können es unter den Bedingungen der GNU Affero General Public License, Version 3, wie von der Free Software Foundation veröffentlicht, weitergeben und/oder modifizieren.",
"licence2": "Die Veröffentlichung von Share₂Fedi erfolgt in der Hoffnung, dass es Ihnen von Nutzen sein wird, aber OHNE IRGENDEINE GARANTIE, sogar ohne die implizite Garantie der MARKTREIFE oder der VERWENDBARKEIT FÜR EINEN BESTIMMTEN ZWECK. Details finden Sie in der GNU Affero General Public License.",
"privacyNotice": "Datenschutzhinweis",
"privacy1": "s2f.kytta.dev wird auf Vercel gehostet. Vercel verarbeitet IP-Adressen, Systemkonfigurationsinformationen und andere Informationen über den Verkehr von und zu s2f.kytta.dev. Vercel speichert diese Informationen nicht und gibt sie auch nicht an Dritte weiter. Siehe {} für weitere Informationen.",
"privacy2": "Wenn Sie auf die „Veröffentlichen“ klicken, werden Sie zu einer Fediverse-Instanz weitergeleitet, die Sie angegeben haben. Diese kann Ihre Daten verarbeiten und/oder speichern. Bitte beachten Sie die Datenschutzrichtlinien der jeweiligen Instanz.",
"vercelPP": "Vercels Datenschutzpolitik",
"postText": "Beitragstext{}",
"postTextPlaceholder": "Was gibts Neues?",
"instance": "Fediverse-Instanz{}",
"previouslyUsed": "Bisher verwendet: {}",
"rememberInstance": "{} Instanz auf diesem Gerät merken",
"publish": "Veröffentlichen"
}

25
i18n/translations/en.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metaDescription": "Share₂Fedi is a share page for Mastodon, Misskey, Friendica, and others. Type in your post text and the instance URL and click Publish!",
"language": "Language: {}",
"description": "Share₂Fedi is an instance-agnostic share page for {}. With it, you can post to various federated platforms from a single page.",
"fediverse": "the Fediverse",
"supportedProjects": "Supported projects:",
"incl": "incl.",
"credits": "Share₂Fedi is developed and maintained by {}. Source code is {}. Hosted with {}. {}.",
"nikita": "Nikita Karamov",
"onGitHub": "on GitHub",
"statusPage": "Status page",
"licence": "Licence",
"licence1": "Share₂Fedi is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation.",
"licence2": "Share₂Fedi is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.",
"privacyNotice": "Privacy Notice",
"privacy1": "s2f.kytta.dev is hosted on Vercel. Vercel processes IP addresses, system configuration information, and other information about traffic to and from s2f.kytta.dev. Vercel does not store this information nor does it get shared with third parties. See {} for more information.",
"privacy2": "When you click the Publish button, youll get redirected to a Fediverse instance youve specified. It may process and/or store your data. Please refer to the privacy policy of the respective instance.",
"vercelPP": "Vercels privacy policy",
"postText": "Post text{}",
"postTextPlaceholder": "Whats on your mind?",
"instance": "Fediverse instance{}",
"previouslyUsed": "Previously used: {}",
"rememberInstance": "{} Remember instance on this device",
"publish": "Publish"
}

25
i18n/translations/es.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metaDescription": "Share₂Fedi es una página de compartir para Mastodon, Misskey, Friendica y otros. ¡Escriba su texto de publicación y la URL de la instancia y haga clic en «Publicar»!",
"language": "Idioma: {}",
"description": "Share₂Fedi es una página de compartir independiente de la instancia para {}. Con ella, puede publicar en varias plataformas federadas desde una sola página.",
"fediverse": "el Fediverso",
"supportedProjects": "Proyectos compatibles:",
"incl": "incl.",
"credits": "Share₂Fedi es desarrollado y mantenido por {}. El código fuente es {}. Alojado con {}. {}.",
"nikita": "Nikita Karamov",
"onGitHub": "en GitHub",
"statusPage": "Página de estado",
"licence": "Licencia",
"licence1": "Share₂Fedi es un software libre: puedes redistribuirlo y/o modificarlo bajo los términos de la Licencia Pública General GNU Affero, versión 3, publicada por la Free Software Foundation.",
"licence2": "Share₂Fedi se distribuye con la esperanza de que sea útil, pero SIN NINGUNA GARANTÍA; sin siquiera la garantía implícita de COMERCIABILIDAD o IDONEIDAD PARA UN PROPÓSITO PARTICULAR. Consulte la Licencia Pública General Affero de GNU para obtener más detalles.",
"privacyNotice": "Aviso de privacidad",
"privacy1": "s2f.kytta.dev está alojado en Vercel. Vercel procesa las direcciones IP, la información de configuración del sistema y otra información sobre el tráfico hacia y desde s2f.kytta.dev. Vercel no almacena esta información ni la comparte con terceros. Consulte {} para obtener más información.",
"privacy2": "Cuando hace clic en el botón «Publicar», se le redirige a una instancia de Fediverso que ha especificado. Puede procesar y/o almacenar sus datos. Consulte la política de privacidad de la instancia correspondiente.",
"vercelPP": "Política de privacidad de Vercel",
"postText": "Texto de la publicación{}",
"postTextPlaceholder": "¿Qué hay de nuevo?",
"instance": "Instancia del Fediverso{}",
"previouslyUsed": "Usado anteriormente: {}",
"rememberInstance": "{} Recordar instancia en este dispositivo",
"publish": "Publicar"
}

25
i18n/translations/fr.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metaDescription": "Share₂Fedi est une page de partage pour Mastodon, Misskey, Friendica et autres. Tapez votre texte de publication et lURL de linstance, puis cliquez sur « Publier » !",
"language": "Langue : {}",
"description": "Share₂Fedi est une page de partage indépendante pour {}. Avec elle, vous pouvez publier sur diverses plateformes fédérées depuis une seule page.",
"fediverse": "le Fediverse",
"supportedProjects": "Projets pris en charge :",
"incl": "dont",
"credits": "Share₂Fedi est développé et maintenu par {}. Le code source est {}. Hébergé avec {}. {}.",
"nikita": "Nikita Karamov",
"onGitHub": "sur GitHub",
"statusPage": "Page de statut",
"licence": "Licence",
"licence1": "Share₂Fedi est un logiciel libre : vous pouvez le redistribuer et/ou le modifier selon les termes de la licence publique générale Affero, version 3, telle que publiée par la Free Software Foundation.",
"licence2": "Share₂Fedi est distribué dans lespoir quil sera utile, mais SANS AUCUNE GARANTIE ; sans même la garantie implicite de QUALITÉ MARCHANDE ou DADÉQUATION À UN USAGE PARTICULIER. Consultez la licence publique générale Affero de GNU pour plus de détails.",
"privacyNotice": "Avis de confidentialité",
"privacy1": "s2f.kytta.dev est hébergé sur Vercel. Vercel traite les adresses IP, les informations de configuration système et dautres informations sur le trafic vers et depuis s2f.kytta.dev. Vercel ne stocke pas ces informations et ne les partage pas avec des tiers. Voir {} pour plus dinformations.",
"privacy2": "Lorsque vous cliquez sur le bouton « Publier », vous êtes redirigé vers une instance du Fediverse que vous avez spécifiée. Elle peut traiter et/ou stocker vos données. Veuillez vous référer à la politique de confidentialité de linstance respective.",
"vercelPP": "Politique de confidentialité de Vercel",
"postText": "Texte de la publication{}",
"postTextPlaceholder": "Quoi de neuf ?",
"instance": "Instance du Fediverse{}",
"previouslyUsed": "Utilisé précédemment : {}",
"rememberInstance": "{} Se souvenir de linstance sur cet appareil",
"publish": "Publier"
}

25
i18n/translations/nl.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metaDescription": "Share₂Fedi is een deelpagina voor Mastodon, Misskey, Friendica en andere fediversenetwerken. Voer een bericht in, plak de instantie-url en klik op Publiceren!",
"language": "Taal: {}",
"description": "Share₂Fedi is een instantie-onafhankelijke deelpagina voor {}. Zo kun je berichten plaatsen op meerdere fediversenetwerken tegelijk.",
"fediverse": "het Fediverse",
"supportedProjects": "Ondersteunde netwerken:",
"incl": "incl.",
"nikita": "Nikita Karamov",
"onGitHub": "op GitHub",
"statusPage": "Statuspagina",
"licence": "Licentie",
"privacyNotice": "Privacybeleid",
"privacy2": "Als je op publiceren klikt, wordt je doorgestuurd naar de opgegeven fediverse-instantie. Hierbij worden je gegevens mogelijk verwerkt en/of bewaard. Bekijk voor meer informatie het privacybeleid van je instantie.",
"vercelPP": "Vercels privacybeleid",
"postTextPlaceholder": "Wat gaat er door je hoofd?",
"instance": "Fediverse-instantie{}",
"postText": "Berichtinhoud{}",
"previouslyUsed": "Onlangs gebruikt: {}",
"rememberInstance": "{} Instantie onthouden op dit apparaat",
"publish": "Publiceren",
"credits": "Share₂Fedi wordt ontwikkeld en onderhouden door {}. Broncode: {} - Hosting: {}. {}.",
"licence1": "Share₂Fedi is vrije software: je mag de software opnieuw uitgeven en/of aanpassen onder de voorwaarden van de GNU Affero General Public License, versie 3, zoals omschreven door de Free Software Foundation.",
"licence2": "Share₂Fedi wordt verspreid in de hoop nuttig te zijn, maar wordt geleverd ZONDER ENIGE VORM VAN GARANTIE; zelfs zonder de garantie van VERKOOPBAARHEID of GESCHIKTHEID VOOR EEN BEPAALD DOEL. Bekijk voor meer informatie de GNU Affero General Public License.",
"privacy1": "s2f.kytta.dev wordt gehost door Vercel. Vercel verwerkt ip-adressen, systeemconfiguraties en andere informatie omtrent verkeer van en naar s2f.kytta.dev. Vercel bewaart deze informatie echter niet, noch deelt het de informatie met externe partijen. Bekijk voor meer informatie {}."
}

25
i18n/translations/ru.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metaDescription": "Share₂Fedi — это share-страница для Mastodon, Misskey, Friendica и других сервисов. Введите текст поста и URL-адрес инстанса и нажмите «Опубликовать»!",
"language": "Язык: {}",
"description": "Share₂Fedi — это инстанс-независимая share-страница для {}. С её помощью вы можете делиться информацией на различных федеративных платформах с одной страницы.",
"fediverse": "Федивёрса",
"supportedProjects": "Поддерживаемые проекты:",
"incl": "вкл.",
"credits": "Share₂Fedi разрабатывается и поддерживается {}. Исходный код {}. Хостится на {}. {}.",
"nikita": "Никитой Карамовым",
"onGitHub": "на GitHub",
"statusPage": "Статус",
"licence": "Лицензия",
"licence1": "Share₂Fedi является свободным программным обеспечением: вы можете распространять её и (или) изменять, соблюдая условия GNU Affero General Public License редакции 3, опубликованной Free Software Foundation.",
"licence2": "Share₂Fedi распространяется в расчёте на то, что она окажется полезной, но БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ, включая подразумеваемую гарантию КАЧЕСТВА либо ПРИГОДНОСТИ ДЛЯ ОПРЕДЕЛЁННЫХ ЦЕЛЕЙ. Ознакомьтесь с GNU Affero General Public License для получения более подробной информации.",
"privacyNotice": "Политика конфиденциальности",
"privacy1": "Сайт s2f.kytta.dev размещён на серверах Vercel. Vercel обрабатывает IP-адреса, информацию о конфигурации системы и другую информацию о трафике, идущем к s2f.kytta.dev и от него. Vercel не хранит эту информацию и не передает её третьим лицам. Более подробную информацию см. в {}.",
"privacy2": "Когда вы нажимаете кнопку «Опубликовать», вы попадаете на указанный вами инстанс Федивёрса. Он может обрабатывать и/или хранить ваши данные. Пожалуйста, ознакомьтесь с политикой конфиденциальности соответствующего инстанса.",
"vercelPP": "Политике конфиденциальности Vercel",
"postText": "Текст поста{}",
"postTextPlaceholder": "О чём думаете?",
"instance": "Инстанс Федивёрса{}",
"previouslyUsed": "Ранее использовались: {}",
"rememberInstance": "{} Запомнить инстанс на этом устройстве",
"publish": "Опубликовать"
}

13
i18n/translations/uk.json Normal file
View File

@@ -0,0 +1,13 @@
{
"privacyNotice": "Політика конфіденційності",
"incl": "вкл.",
"postTextPlaceholder": "Що у вас на думці?",
"rememberInstance": "{} Запам'ятати інстанс на цьому пристрої",
"metaDescription": "Share₂Fedi — це share-сторінка для Mastodon, Misskey, Friendica та інших сервісів. Введіть текст посту та URL-адресу інстансу та натисніть «Опублікувати»!",
"statusPage": "Статус",
"publish": "Опублікувати",
"licence": "Ліцензія",
"language": "Мова: {}",
"supportedProjects": "Підтримувані проєкти:",
"onGitHub": "на GitHub"
}

16
islands/Counter.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Signal } from "@preact/signals";
import { Button } from "../components/Button.tsx";
interface CounterProps {
count: Signal<number>;
}
export default function Counter(props: CounterProps) {
return (
<div class="flex gap-8 py-6">
<Button onClick={() => props.count.value -= 1}>-1</Button>
<p class="text-3xl tabular-nums">{props.count}</p>
<Button onClick={() => props.count.value += 1}>+1</Button>
</div>
);
}

60
lib/instance.ts Normal file
View File

@@ -0,0 +1,60 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { supportedProjects } from "$lib/project.ts";
interface Instance {
domain: string;
score: number;
active_users_monthly: number;
total_users: number;
}
const getInstancesForProject = async (
project: keyof typeof supportedProjects,
): Promise<Instance[]> => {
let instances: Instance[];
try {
const response = await fetch("https://api.fediverse.observer/", {
headers: {
Accept: "*/*",
"Accept-Language": "en;q=1.0",
"Content-Type": "application/json",
},
body: JSON.stringify({
query:
`{nodes(status:"UP",softwarename:"${project}"){domain score active_users_monthly total_users}}`,
}),
method: "POST",
});
const json = await response.json();
instances = json.data.nodes;
} catch (error) {
console.error(`Could not fetch instances for "${project}"`, error);
return [];
}
return instances.filter(
(instance) =>
instance.score > 90 &&
// sanity check for some spammy-looking instances
instance.total_users >= instance.active_users_monthly,
);
};
export const getPopularInstanceDomains = async (): Promise<string[]> => {
const instancesPerProject = await Promise.all(
Object.keys(supportedProjects).map((project) =>
getInstancesForProject(project)
),
);
const instances = instancesPerProject.flat();
instances.sort((a, b) => b.active_users_monthly - a.active_users_monthly);
return instances.slice(0, 200).map((instance) => instance.domain);
};

61
lib/nodeinfo.ts Normal file
View File

@@ -0,0 +1,61 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { normalizeURL } from "$lib/url.ts";
interface NodeInfoList {
links: {
rel: string;
href: string;
}[];
}
interface NodeInfo {
software: {
name: string;
version: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export const getSoftwareName = async (
domain: string,
): Promise<string | undefined> => {
const nodeInfoListUrl = new URL(
"/.well-known/nodeinfo",
normalizeURL(domain),
);
let nodeInfoList: NodeInfoList;
try {
const nodeInfoListResponse = await fetch(nodeInfoListUrl);
nodeInfoList = await nodeInfoListResponse.json();
} catch (error) {
console.error("Could not fetch '.well-known/nodeinfo':", error);
return undefined;
}
for (const link of nodeInfoList.links) {
if (
/^http:\/\/nodeinfo\.diaspora\.software\/ns\/schema\/(1\.0|1\.1|2\.0|2\.1)/
.test(
link.rel,
)
) {
const nodeInfoResponse = await fetch(link.href);
const nodeInfo = (await nodeInfoResponse.json()) as NodeInfo;
return nodeInfo.software.name;
}
}
// not found
console.warn("No NodeInfo found for domain:", domain);
return undefined;
};

64
lib/project.ts Normal file
View File

@@ -0,0 +1,64 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface ProjectPublishConfig {
endpoint: string;
params: {
text: string;
};
}
const mastodonConfig: ProjectPublishConfig = {
endpoint: "share",
params: {
text: "text",
},
};
const misskeyConfig: ProjectPublishConfig = {
endpoint: "share",
params: {
text: "text",
},
};
/**
* Mapping of the supported fediverse projects.
*
* The keys of this mapping can be used as keys for the fediverse.observer API,
* icon names, etc.
*/
export const supportedProjects: Record<string, ProjectPublishConfig> = {
calckey: misskeyConfig,
fedibird: mastodonConfig,
firefish: misskeyConfig,
foundkey: misskeyConfig,
friendica: {
endpoint: "compose",
params: {
text: "body",
},
},
glitchcafe: mastodonConfig,
gnusocial: {
endpoint: "notice/new",
params: {
text: "status_textarea",
},
},
hometown: mastodonConfig,
hubzilla: {
endpoint: "rpost",
params: {
text: "body",
},
},
mastodon: mastodonConfig,
meisskey: misskeyConfig,
misskey: misskeyConfig,
};

30
lib/response.ts Normal file
View File

@@ -0,0 +1,30 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const json = (
body: unknown,
status: number = 200,
headers: Record<string, string> = {},
) => {
return new Response(JSON.stringify(body), {
headers: {
"Content-Type": "application/json",
...headers,
},
status,
});
};
export const error = (message: string, status: number = 400) => {
return json(
{
error: message,
},
status,
);
};

35
lib/url.ts Normal file
View File

@@ -0,0 +1,35 @@
/*!
* This file is part of Share₂Fedi
* https://github.com/kytta/share2fedi
*
* SPDX-FileCopyrightText: © 2023 Nikita Karamov <me@kytta.dev>
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Adds missing "https://" and ending slash to the URL
*
* @param url URL to normalize
* @return normalized URL
*/
export const normalizeURL = (url: string): string => {
if (!(url.startsWith("https://") || url.startsWith("http://"))) {
url = "https://" + url;
}
if (!url.endsWith("/")) {
url += "/";
}
return url;
};
export const getUrlDomain = (url: string | URL): string => {
if (typeof url === "string") {
url = url.trim();
if (!/^https?:\/\//.test(url)) {
url = `https://${url}`;
}
}
return new URL(url).host;
};

13
main.ts Normal file
View File

@@ -0,0 +1,13 @@
/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import "@std/dotenv/load";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
await start(manifest, config);

27
routes/_404.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Head } from "$fresh/runtime.ts";
export default function Error404() {
return (
<>
<Head>
<title>404 - Page not found</title>
</Head>
<div class="px-4 py-8 mx-auto bg-[#86efac]">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<img
class="my-6"
src="/logo.svg"
width="128"
height="128"
alt="the Fresh logo: a sliced lemon dripping with juice"
/>
<h1 class="text-4xl font-bold">404 - Page not found</h1>
<p class="my-4">
The page you were looking for doesn't exist.
</p>
<a href="/" class="underline">Go back home</a>
</div>
</div>
</>
);
}

22
routes/_app.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { type PageProps } from "$fresh/server.ts";
export default function App({ Component, state }: PageProps) {
return (
<html lang={state.prerenderLanguage as string}>
<head>
<meta charset="utf-8" />
<title>ShareFedi</title>
<meta
name="description"
content="Share₂Fedi is a share page for Mastodon, Misskey, Friendica, and others. Type in your post text and the instance URL and click Publish!"
data-translate="metaDescription"
data-translate-attribute="content"
/>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<Component />
</body>
</html>
);
}

35
routes/_middleware.ts Normal file
View File

@@ -0,0 +1,35 @@
import { FreshContext } from "$fresh/server.ts";
import { defaultLanguage, languages } from "$i18n/translations.ts";
interface State {
languages: string[];
prerenderLanguage: string;
}
const chooseLanguage = (localeTags: string[]): string => {
for (const tag of localeTags) {
const locale = new Intl.Locale(tag);
const minimized = locale.minimize();
for (const candidate of [locale.baseName, minimized.baseName]) {
if (candidate in languages) {
return candidate;
}
}
}
return defaultLanguage;
};
export function handler(
req: Request,
ctx: FreshContext<State>,
) {
const acceptLanguages = req.headers.get("Accept-Language")
?.split(",")
.filter(Boolean)
.map((tag) => tag.split(";")[0].trim())
.filter((tag) => tag !== "*") ?? [];
ctx.state.prerenderLanguage = chooseLanguage(acceptLanguages);
return ctx.next();
}

View File

@@ -0,0 +1,37 @@
import type { FreshContext, Handlers } from "$fresh/server.ts";
import { getSoftwareName } from "$lib/nodeinfo.ts";
import { type ProjectPublishConfig, supportedProjects } from "$lib/project.ts";
import { error, json } from "$lib/response.ts";
type Detection = {
domain: string;
project: keyof typeof supportedProjects;
} & ProjectPublishConfig;
export const handler: Handlers<Detection> = {
async GET(_req: Request, ctx: FreshContext): Promise<Response> {
const domain = ctx.params.domain;
const softwareName = await getSoftwareName(domain);
if (softwareName === undefined) {
return error("Could not detect Fediverse project.");
}
if (!(softwareName in supportedProjects)) {
return error(`Fediverse project "${softwareName}" is not supported yet.`);
}
const publishConfig =
supportedProjects[softwareName] as ProjectPublishConfig;
return json(
{
domain,
project: softwareName,
...publishConfig,
},
200,
{
"Cache-Control": "public, s-maxage=86400, max-age=604800",
},
);
},
};

15
routes/api/instances.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { FreshContext, Handlers } from "$fresh/server.ts";
import { getPopularInstanceDomains } from "$lib/instance.ts";
import { json } from "$lib/response.ts";
export const handler: Handlers<string[]> = {
async GET(_req: Request, _ctx: FreshContext): Promise<Response> {
const popularInstanceDomains = await getPopularInstanceDomains();
return json(popularInstanceDomains, 200, {
"Cache-Control": popularInstanceDomains.length > 0
? "public, s-maxage=86400, max-age=604800"
: "public, s-maxage=60, max-age=3600",
});
},
};

5
routes/greet/[name].tsx Normal file
View File

@@ -0,0 +1,5 @@
import { PageProps } from "$fresh/server.ts";
export default function Greet(props: PageProps) {
return <div>Hello {props.params.name}</div>;
}

71
routes/index.tsx Normal file
View File

@@ -0,0 +1,71 @@
import type { FreshContext, Handlers } from "$fresh/server.ts";
import { useSignal } from "@preact/signals";
import Counter from "../islands/Counter.tsx";
import { type ProjectPublishConfig, supportedProjects } from "$lib/project.ts";
import { getUrlDomain } from "$lib/url.ts";
import { seeOther } from "@http/response/see-other";
type Detection = {
domain: string;
project: keyof typeof supportedProjects;
} & ProjectPublishConfig;
import { z } from "zod";
const postSchema = z.object({
text: z.string({
required_error: "Please enter post text.",
}),
instance: z.string({
required_error: "Please enter instance domain.",
}),
});
type POST = z.infer<typeof postSchema>;
export const handler: Handlers = {
async POST(req: Request, ctx: FreshContext): Promise<Response> {
const formData = await req.formData();
const form = postSchema.safeParse(formData);
if (!form.success) {
const errors = form.error?.format();
return await ctx.render({ errors });
}
const instance = getUrlDomain(form.data.instance);
const detectResponse = await fetch(
new URL(`/api/detect/${instance}`, ctx.url),
);
const { domain, endpoint, params } = await detectResponse.json();
const publishUrl = new URL(endpoint, `https://${domain}/`);
publishUrl.search = new URLSearchParams([
[params.text, form.data.text],
]).toString();
return seeOther(publishUrl);
},
};
export default function Home() {
const count = useSignal(3);
return (
<div class="px-4 py-8 mx-auto bg-[#86efac]">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<img
class="my-6"
src="/logo.svg"
width="128"
height="128"
alt="the Fresh logo: a sliced lemon dripping with juice"
/>
<h1 class="text-4xl font-bold">Welcome to Fresh</h1>
<p class="my-4">
Try updating this message in the
<code class="mx-2">./routes/index.tsx</code> file, and refresh.
</p>
<Counter count={count} />
</div>
</div>
);
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

6
static/logo.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.092 8.845C38.929 20.652 34.092 27 30 30.5c1 3.5-2.986 4.222-4.5 2.5-4.457 1.537-13.512 1.487-20-5C2 24.5 4.73 16.714 14 11.5c8-4.5 16-7 20.092-2.655Z" fill="#FFDB1E"/>
<path d="M14 11.5c6.848-4.497 15.025-6.38 18.368-3.47C37.5 12.5 21.5 22.612 15.5 25c-6.5 2.587-3 8.5-6.5 8.5-3 0-2.5-4-5.183-7.75C2.232 23.535 6.16 16.648 14 11.5Z" fill="#fff" stroke="#FFDB1E"/>
<path d="M28.535 8.772c4.645 1.25-.365 5.695-4.303 8.536-3.732 2.692-6.606 4.21-7.923 4.83-.366.173-1.617-2.252-1.617-1 0 .417-.7 2.238-.934 2.326-1.365.512-4.223 1.29-5.835 1.29-3.491 0-1.923-4.754 3.014-9.122.892-.789 1.478-.645 2.283-.645-.537-.773-.534-.917.403-1.546C17.79 10.64 23 8.77 25.212 8.42c.366.014.82.35.82.629.41-.14 2.095-.388 2.503-.278Z" fill="#FFE600"/>
<path d="M14.297 16.49c.985-.747 1.644-1.01 2.099-2.526.566.121.841-.08 1.29-.701.324.466 1.657.608 2.453.701-.715.451-1.057.852-1.452 2.106-1.464-.611-3.167-.302-4.39.42Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

129
static/styles.css Normal file
View File

@@ -0,0 +1,129 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
button {
color: inherit;
}
button, [role="button"] {
cursor: pointer;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
img,
svg {
display: block;
}
img,
video {
max-width: 100%;
height: auto;
}
html {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.transition-colors {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.my-6 {
margin-bottom: 1.5rem;
margin-top: 1.5rem;
}
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.my-4 {
margin-bottom: 1rem;
margin-top: 1rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-8 {
padding-bottom: 2rem;
padding-top: 2rem;
}
.bg-\[\#86efac\] {
background-color: #86efac;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.py-6 {
padding-bottom: 1.5rem;
padding-top: 1.5rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.py-1 {
padding-bottom: 0.25rem;
padding-top: 0.25rem;
}
.border-gray-500 {
border-color: #6b7280;
}
.bg-white {
background-color: #fff;
}
.flex {
display: flex;
}
.gap-8 {
grid-gap: 2rem;
gap: 2rem;
}
.font-bold {
font-weight: 700;
}
.max-w-screen-md {
max-width: 768px;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.border-2 {
border-width: 2px;
}
.rounded {
border-radius: 0.25rem;
}
.hover\:bg-gray-200:hover {
background-color: #e5e7eb;
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}