🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Update description texts

juprodh aa661113 8805caf6

+101 -42
+16 -2
src/lib/i18n/en.ts
··· 24 24 noWikisYet: "No wikis yet.", 25 25 public: "public", 26 26 private: "private", 27 - tagline: "Knowledge grows together", 27 + tagline: "Shared knowledge, owned by you", 28 28 heroDescription: 29 - "Open, collaborative wikis built on ATProto. Your content lives on your identity, not our servers.", 29 + "Collaborative wikis on the open web. Your contributions belong to you, not to any platform.", 30 30 createAWiki: "Create a wiki", 31 31 exploreWikis: "Explore wikis", 32 32 explorePublicWikis: "Explore public wikis", ··· 91 91 noMembers: "No members yet.", 92 92 noRequests: "No pending requests.", 93 93 cannotRemoveOwner: "Cannot remove the wiki owner.", 94 + roleAdmin: "Admin", 95 + roleContributor: "Contributor", 96 + roleViewer: "Viewer", 97 + roleOwner: "Owner", 94 98 changeRole: "Change role", 95 99 saveRole: "Save", 96 100 addMember: "Add member", ··· 107 111 noBookmarks: "No bookmarks yet.", 108 112 bookmark: "Bookmark", 109 113 removeBookmark: "Remove bookmark", 114 + }, 115 + settings: { 116 + heading: "Settings", 117 + dangerZone: "Delete this wiki", 118 + deleteWikiDescription: 119 + "This will permanently delete the wiki and all its notes. This action cannot be undone.", 120 + deleteWikiOwnerOnly: "Only the wiki owner can delete this wiki.", 121 + typeToConfirm: "Type {name} to confirm", 122 + deleteWiki: "Delete wiki", 123 + nameDoesNotMatch: "Wiki name does not match.", 110 124 }, 111 125 error: { 112 126 titleRequired: "Title is required.",
+26 -12
src/lib/i18n/fr.ts
··· 3 3 export const fr: PartialMessages = { 4 4 nav: { 5 5 login: "Connexion", 6 - logout: "Deconnexion", 6 + logout: "Déconnexion", 7 7 }, 8 8 login: { 9 - heading: "Connexion a Lichen", 9 + heading: "Connexion à Lichen", 10 10 description: 11 11 "Entrez votre identifiant pour vous connecter via votre fournisseur ATProto.", 12 12 handleLabel: "Identifiant", 13 13 handlePlaceholder: "alice.bsky.social", 14 14 errorResolve: 15 - "Impossible de resoudre l'identifiant \"{handle}\". Verifiez l'orthographe.", 16 - errorGeneric: "La connexion a echoue. Veuillez reessayer.", 15 + "Impossible de résoudre l'identifiant \"{handle}\". Vérifiez l'orthographe.", 16 + errorGeneric: "La connexion a échoué. Veuillez réessayer.", 17 17 }, 18 18 search: { 19 19 searchWikis: "Rechercher des wikis...", ··· 25 25 noWikisYet: "Aucun wiki pour le moment.", 26 26 public: "public", 27 27 private: "privé", 28 - tagline: "Le savoir grandit ensemble", 28 + tagline: "Écrivez ensemble, tout vous appartient", 29 29 heroDescription: 30 - "Des wikis ouverts et collaboratifs sur ATProto. Votre contenu vit sur votre identite, pas sur nos serveurs.", 31 - createAWiki: "Creer un wiki", 30 + "Des wikis collaboratifs sur le web libre. Vos contributions vous appartiennent, sans dépendre d'aucune plateforme.", 31 + createAWiki: "Créer un wiki", 32 32 exploreWikis: "Explorer les wikis", 33 33 explorePublicWikis: "Explorer les wikis publics", 34 34 allLanguages: "Tous", 35 35 noteCount: "{count} notes", 36 36 builtOnAtproto: "Construit sur ATProto", 37 37 sourceCode: "Code source", 38 - recentlyUpdated: "Mis a jour recemment", 39 - recentlyCreated: "Crees recemment", 38 + recentlyUpdated: "Mis à jour récemment", 39 + recentlyCreated: "Créés récemment", 40 40 exploreMore: "Explorer plus", 41 41 }, 42 42 pagination: { 43 - previous: "Precedent", 43 + previous: "Précédent", 44 44 next: "Suivant", 45 45 }, 46 46 wiki: { ··· 60 60 cancel: "Annuler", 61 61 deleteNote: "Supprimer la note", 62 62 confirmDeleteNote: 63 - "Voulez-vous vraiment supprimer cette note ? Cette action est irreversible.", 63 + "Voulez-vous vraiment supprimer cette note ? Cette action est irréversible.", 64 64 newNotePlaceholder: "# Titre de ma note\n\nÉcrivez votre contenu ici...", 65 65 titlePlaceholder: "Titre de ma note", 66 66 }, ··· 92 92 noMembers: "Aucun membre pour le moment.", 93 93 noRequests: "Aucune demande en attente.", 94 94 cannotRemoveOwner: "Impossible de supprimer le propriétaire du wiki.", 95 + roleAdmin: "Administrateur", 96 + roleContributor: "Contributeur", 97 + roleViewer: "Lecteur", 98 + roleOwner: "Propriétaire", 95 99 changeRole: "Changer le rôle", 96 100 saveRole: "Enregistrer", 97 101 addMember: "Ajouter un membre", ··· 103 107 myWikis: "Mes wikis", 104 108 collaborating: "Collaborations", 105 109 bookmarks: "Signets", 106 - noOwnedWikis: "Vous n'avez pas encore cree de wiki.", 110 + noOwnedWikis: "Vous n'avez pas encore créé de wiki.", 107 111 noCollaboratingWikis: "Vous ne collaborez sur aucun wiki pour le moment.", 108 112 noBookmarks: "Aucun signet pour le moment.", 109 113 bookmark: "Ajouter aux signets", 110 114 removeBookmark: "Retirer des signets", 115 + }, 116 + settings: { 117 + heading: "Paramètres", 118 + dangerZone: "Supprimer ce wiki", 119 + deleteWikiDescription: 120 + "Cela supprimera définitivement le wiki et toutes ses notes. Cette action est irréversible.", 121 + deleteWikiOwnerOnly: "Seul le propriétaire du wiki peut le supprimer.", 122 + typeToConfirm: "Tapez {name} pour confirmer", 123 + deleteWiki: "Supprimer le wiki", 124 + nameDoesNotMatch: "Le nom du wiki ne correspond pas.", 111 125 }, 112 126 error: { 113 127 titleRequired: "Le titre est requis.",
+13
src/lib/i18n/index.ts
··· 92 92 noMembers: string; 93 93 noRequests: string; 94 94 cannotRemoveOwner: string; 95 + roleAdmin: string; 96 + roleContributor: string; 97 + roleViewer: string; 98 + roleOwner: string; 95 99 changeRole: string; 96 100 saveRole: string; 97 101 addMember: string; ··· 108 112 noBookmarks: string; 109 113 bookmark: string; 110 114 removeBookmark: string; 115 + }; 116 + settings: { 117 + heading: string; 118 + dangerZone: string; 119 + deleteWikiDescription: string; 120 + deleteWikiOwnerOnly: string; 121 + typeToConfirm: string; 122 + deleteWiki: string; 123 + nameDoesNotMatch: string; 111 124 }; 112 125 error: { 113 126 titleRequired: string;
+1 -1
src/views/home.ts
··· 27 27 "Lichen", 28 28 ` 29 29 <section class="text-center py-8 px-6"> 30 - <div class="inline-block w-4 h-4 rounded-full ${THEME.accentBg} mb-3"></div> 30 + <svg width="32" height="32" viewBox="0 0 16 16" class="inline-block mb-3"><circle cx="6" cy="9" r="4.5" fill="currentColor" class="${THEME.accentText}" opacity="0.7"/><circle cx="10" cy="9" r="3.5" fill="currentColor" class="${THEME.accentText}" opacity="0.5"/><circle cx="8" cy="5" r="3" fill="currentColor" class="${THEME.accentText}" opacity="0.85"/></svg> 31 31 <h1 class="text-3xl font-bold ${THEME.textPrimary} mb-2">Lichen</h1> 32 32 <p class="text-lg ${THEME.textSecondary} mb-1">${msg.home.tagline}</p> 33 33 <p class="text-sm ${THEME.textMuted} max-w-md mx-auto mb-5">${msg.home.heroDescription}</p>
+1
src/views/icons.ts
··· 5 5 user: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`, 6 6 login: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>`, 7 7 logout: `<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`, 8 + check: `<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`, 8 9 } as const;
+3 -3
src/views/layout.ts
··· 14 14 15 15 const LOCALE_LABELS: Record<string, string> = { 16 16 en: "English", 17 - fr: "Francais", 17 + fr: "Français", 18 18 }; 19 19 20 20 export interface LayoutOptions { ··· 68 68 ? `<div class="pt-3 border-t ${THEME.borderSubtle} shrink-0"> 69 69 <a href="/wiki/${options.wikiSlug}/-/settings" class="${sidebarLinkClass}"> 70 70 ${ICONS.settings} 71 - Settings 71 + ${msg.settings.heading} 72 72 </a> 73 73 </div>` 74 74 : ""; ··· 151 151 <nav class="${THEME.bgSurface} border-b ${THEME.borderDefault}"> 152 152 <div class="max-w-6xl mx-auto px-6 py-3 flex items-center justify-between gap-4"> 153 153 <div class="flex items-center gap-2 min-w-0"> 154 - <a href="/" class="flex items-center gap-2 shrink-0"><span class="inline-block w-3 h-3 rounded-full ${THEME.accentBg}"></span><span class="text-lg font-semibold ${THEME.accentText}">Lichen</span></a> 154 + <a href="/" class="flex items-center gap-2 shrink-0"><svg width="16" height="16" viewBox="0 0 16 16" class="shrink-0"><circle cx="6" cy="9" r="4.5" fill="currentColor" class="${THEME.accentText}" opacity="0.7"/><circle cx="10" cy="9" r="3.5" fill="currentColor" class="${THEME.accentText}" opacity="0.5"/><circle cx="8" cy="5" r="3" fill="currentColor" class="${THEME.accentText}" opacity="0.85"/></svg><span class="text-lg font-semibold ${THEME.accentText}">Lichen</span></a> 155 155 ${options?.wikiName ? `<span class="${THEME.textMuted}">/</span><a href="/wiki/${options.wikiSlug}" class="text-lg font-medium ${THEME.textSecondary} ${THEME.accentHoverText} truncate">${escapeHtml(options.wikiName)}</a>` : ""} 156 156 </div> 157 157 <div class="flex items-center gap-2 shrink-0">
+39 -24
src/views/settings.ts
··· 1 1 import { escapeHtml } from "../lib/html.ts"; 2 - import { t } from "../lib/i18n/index.ts"; 2 + import { fmt, t } from "../lib/i18n/index.ts"; 3 3 import type { ProfileInfo } from "../lib/profile.ts"; 4 4 import type { MembershipRow, RequestRow } from "../server/db/queries/index.ts"; 5 + import { ICONS } from "./icons.ts"; 5 6 import { type LayoutOptions, layout } from "./layout.ts"; 6 7 import { 7 8 dangerButtonClass, 9 + dangerSmallButtonClass, 8 10 errorBanner, 9 11 primarySmallButtonClass, 10 12 THEME, ··· 43 45 ): string { 44 46 const msg = t(locale as "en" | "fr"); 45 47 48 + const roleLabels: Record<string, string> = { 49 + admin: msg.access.roleAdmin, 50 + contributor: msg.access.roleContributor, 51 + viewer: msg.access.roleViewer, 52 + owner: msg.access.roleOwner, 53 + }; 54 + const roleLabel = (role: string) => roleLabels[role] ?? role; 55 + 46 56 const roleOptions = (current: string) => 47 57 ["contributor", "admin", "viewer"] 48 58 .map( 49 59 (r) => 50 - `<option value="${r}"${r === current ? " selected" : ""}>${r}</option>`, 60 + `<option value="${r}"${r === current ? " selected" : ""}>${roleLabel(r)}</option>`, 51 61 ) 52 62 .join(""); 53 63 ··· 55 65 .map((m) => { 56 66 const isOwner = m.did === wikiDid; 57 67 const roleCell = isOwner 58 - ? `<span class="text-sm">${escapeHtml(m.role)}</span>` 68 + ? `<span class="text-sm">${roleLabel("owner")}</span>` 59 69 : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/change-role" class="inline-flex items-center gap-1"> 60 - <select name="role" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 70 + <select name="role" data-original="${m.role}" onchange="this.nextElementSibling.classList.toggle('hidden', this.value === this.dataset.original)" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 61 71 ${roleOptions(m.role)} 62 72 </select> 63 - <button type="submit" class="text-xs ${THEME.accentText} hover:underline">${msg.access.saveRole}</button> 73 + <button type="submit" class="hidden p-1 ${THEME.accentBg} text-white rounded ${THEME.accentDarkHoverBg} cursor-pointer" title="${msg.access.saveRole}">${ICONS.check}</button> 64 74 </form>`; 65 75 const removeButton = isOwner 66 - ? `<span class="text-xs ${THEME.textMuted}" title="${msg.access.cannotRemoveOwner}">—</span>` 76 + ? "" 67 77 : `<form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(m.did)}/remove" class="inline"> 68 - <button type="submit" class="text-xs ${THEME.errorText} hover:underline">${msg.access.remove}</button> 78 + <button type="submit" class="${dangerSmallButtonClass} cursor-pointer">${msg.access.remove}</button> 69 79 </form>`; 70 80 return `<tr class="border-b ${THEME.borderSubtle}"> 71 81 <td class="py-2 pr-4">${renderIdentity(m.did, profiles.get(m.did))}</td> ··· 84 94 <td class="py-2 text-sm"> 85 95 <form method="POST" action="/wiki/${wikiSlug}/-/members/${encodeURIComponent(r.did)}/approve" class="inline-flex items-center gap-2"> 86 96 <select name="role" class="text-xs border ${THEME.borderDefault} rounded px-1 py-0.5"> 87 - <option value="contributor">contributor</option> 88 - <option value="admin">admin</option> 89 - <option value="viewer">viewer</option> 97 + <option value="contributor">${roleLabel("contributor")}</option> 98 + <option value="admin">${roleLabel("admin")}</option> 99 + <option value="viewer">${roleLabel("viewer")}</option> 90 100 </select> 91 - <button type="submit" class="text-xs ${THEME.accentText} hover:underline">${msg.access.approve}</button> 101 + <button type="submit" class="${primarySmallButtonClass} cursor-pointer">${msg.access.approve}</button> 92 102 </form> 93 103 </td> 94 104 </tr>`, ··· 139 149 <div class="flex flex-col gap-1"> 140 150 <label class="text-xs font-medium ${THEME.textMuted}">${msg.access.role}</label> 141 151 <select name="role" class="text-sm border ${THEME.borderInput} rounded px-2 py-1.5 focus:outline-none ${THEME.accentInputFocusBorder}"> 142 - <option value="contributor">contributor</option> 143 - <option value="admin">admin</option> 144 - <option value="viewer">viewer</option> 152 + <option value="contributor">${roleLabel("contributor")}</option> 153 + <option value="admin">${roleLabel("admin")}</option> 154 + <option value="viewer">${roleLabel("viewer")}</option> 145 155 </select> 146 156 </div> 147 157 <button type="submit" class="${primarySmallButtonClass}">${msg.access.add}</button> ··· 153 163 wikiName: string, 154 164 wikiSlug: string, 155 165 isOwner: boolean, 166 + locale: string, 156 167 ): string { 168 + const msg = t(locale as "en" | "fr"); 169 + 157 170 if (!isOwner) { 158 171 return `<section class="mt-10 border ${THEME.borderDefault} rounded-lg p-6"> 159 - <h2 class="text-lg font-semibold ${THEME.textMuted} mb-2">Danger zone</h2> 160 - <p class="text-sm ${THEME.textMuted}">Only the wiki owner can delete this wiki.</p> 172 + <h2 class="text-lg font-semibold ${THEME.textMuted} mb-2">${msg.settings.dangerZone}</h2> 173 + <p class="text-sm ${THEME.textMuted}">${msg.settings.deleteWikiOwnerOnly}</p> 161 174 </section>`; 162 175 } 163 176 164 177 return `<section class="mt-10 border ${THEME.errorBorder} rounded-lg p-6 ${THEME.errorBg}"> 165 - <h2 class="text-lg font-semibold ${THEME.errorText} mb-4">Danger zone</h2> 178 + <h2 class="text-lg font-semibold ${THEME.errorText} mb-4">${msg.settings.dangerZone}</h2> 166 179 <p class="text-sm ${THEME.textSecondary} mb-4"> 167 - Permanently delete this wiki and all its notes. This cannot be undone. 180 + ${msg.settings.deleteWikiDescription} 168 181 </p> 169 182 <form method="POST" action="/wiki/${wikiSlug}/-/delete" 170 183 onsubmit="return document.getElementById('confirm-name').value === '${escapeHtml(wikiName)}' 171 - || (alert('Wiki name does not match.'), false)"> 184 + || (alert('${msg.settings.nameDoesNotMatch}'), false)"> 172 185 <label class="block text-sm font-medium ${THEME.textSecondary} mb-1"> 173 - Type <strong>${escapeHtml(wikiName)}</strong> to confirm 186 + ${fmt(msg.settings.typeToConfirm, { name: `<strong>${escapeHtml(wikiName)}</strong>` })} 174 187 </label> 175 188 <input id="confirm-name" type="text" name="confirm" autocomplete="off" 176 189 class="block w-full max-w-sm px-3 py-1.5 text-sm border ${THEME.borderInput} rounded mb-3 focus:outline-none ${THEME.accentInputFocusBorder}" 177 190 placeholder="${escapeHtml(wikiName)}" /> 178 - <button type="submit" class="${dangerButtonClass}">Delete wiki</button> 191 + <button type="submit" class="${dangerButtonClass}">${msg.settings.deleteWiki}</button> 179 192 </form> 180 193 </section>`; 181 194 } ··· 198 211 options.wikiDid, 199 212 options.locale ?? "en", 200 213 ); 201 - const dangerHtml = renderDangerZone(wikiName, wikiSlug, isOwner); 214 + const locale = options.locale ?? "en"; 215 + const msg = t(locale); 216 + const dangerHtml = renderDangerZone(wikiName, wikiSlug, isOwner, locale); 202 217 203 218 return layout( 204 - `Settings — ${wikiName}`, 205 - `<h1 class="text-2xl font-bold mb-8">Settings</h1>${errorHtml}${membersHtml}${dangerHtml}`, 219 + `${msg.settings.heading} — ${wikiName}`, 220 + `<h1 class="text-2xl font-bold mb-8">${msg.settings.heading}</h1>${errorHtml}${membersHtml}${dangerHtml}`, 206 221 { ...options, wikiName, wikiSlug }, 207 222 ); 208 223 }
+2
src/views/theme.ts
··· 65 65 66 66 export const dangerButtonClass = `px-4 py-2 ${THEME.errorActionBg} text-white text-sm font-medium rounded ${THEME.errorActionDarkHoverBg}`; 67 67 68 + export const dangerSmallButtonClass = `px-2.5 py-1 ${THEME.errorActionBg} text-white text-xs font-medium rounded ${THEME.errorActionDarkHoverBg}`; 69 + 68 70 export const sidebarButtonClass = `w-full flex items-center gap-2 px-3 py-1.5 text-sm border ${THEME.borderDefault} rounded-lg ${THEME.bgSurface} ${THEME.textMuted} ${THEME.accentLightHoverBg} cursor-pointer`; 69 71 70 72 export const sidebarLinkClass = `flex items-center gap-2 px-3 py-1.5 text-sm ${THEME.textSecondary} ${THEME.accentLightHoverBg} rounded-lg`;