Compare commits
14 commits
00d7e1afc7
...
da7d75641d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7d75641d | ||
|
|
9cd6667e4a | ||
|
|
c1afb0aa2c | ||
|
|
6873a8d920 | ||
|
|
bf8538bbb9 | ||
|
|
17f6216e67 | ||
|
|
d01128d54f | ||
|
|
93b74d0179 | ||
|
|
4295a83ee1 | ||
|
|
f7a504d5a3 | ||
|
|
262b8c578f | ||
|
|
382f3dd0a2 | ||
|
|
58e04edaba | ||
|
|
e11f0a6b8b |
25
README.md
|
|
@ -1,28 +1,20 @@
|
|||
# Viossa.net
|
||||
# Viossa.net kotoba-tumam
|
||||
|
||||
bråtula viossa.net måde! We're here to build an informational website about Viossa.
|
||||
|
||||
## The Stack
|
||||
**What will we be using to build this site?**
|
||||
|
||||
### Core
|
||||
### Backend
|
||||
- [Node.js](https://nodejs.org/)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [pnpm](https://pnpm.io/)
|
||||
- [Turborepo](https://turborepo.com/)
|
||||
|
||||
### Frontend
|
||||
- [Vue 3](https://vuejs.org/)
|
||||
- [Vite](https://vite.dev/)
|
||||
- The ktb frontend is purely coded in HTML, CSS, and browser JS.
|
||||
- [AlpineJS](https://alpinejs.dev/)
|
||||
|
||||
Additionally, we will be following [**atomic design principles**](https://bradfrost.com/blog/post/atomic-web-design/) to organize the components of the project.
|
||||
|
||||
### Styling
|
||||
- [Bulma](https://bulma.io/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Sass](https://sass-lang.com/)
|
||||
|
||||
### Backend
|
||||
- [Node.js](https://nodejs.org/)
|
||||
|
||||
### Linting
|
||||
- [Prettier](https://prettier.io/)
|
||||
|
|
@ -46,15 +38,12 @@ Additionally, we will be following [**atomic design principles**](https://bradfr
|
|||
This project uses Turborepo for task management/caching. Install Turborepo globally on your machine to allow for executing turbo commands more easily: `pnpm i -g turbo` (this is needed to continue with the instructions below)
|
||||
|
||||
### Frontend (Viossa.net)
|
||||
1. Ensure you're in the root directory of the project (`ViossaDotNet`)
|
||||
1. Move into the app's directory: `cd apps/vdn-static`
|
||||
1. To run the site, use `turbo dev`. This will set up watchers to build all libraries used by the frontend, as well as hot-refreshing the site as changes are made to it.
|
||||
1. To view the website running locally, visit http://localhost:1224/ in your browser!
|
||||
1. The frontend no longer requires a compilation step. Yippee!
|
||||
|
||||
### Backend (Viossa DB)
|
||||
1. Ensure you're in the root directory of the project (`ViossaDotNet`)
|
||||
1. Move into the app's directory: `cd apps/vdb-backend`
|
||||
1. To run the site, use `turbo start`. This will build all of the app's dependencies and then start the application.
|
||||
1. To run the API, use `turbo start`. This will build all of the app's dependencies and then start the application.
|
||||
1. **NOTE:** Backend apps are not watched/hot-refreshed like frontend apps! If you make changes, you must kill the app and re-run it to apply changes.
|
||||
1. To view a sample response from the backend API, visit http://localhost:1225/sample in your browser!
|
||||
|
||||
|
|
|
|||
254
apps/ktb-static/script.js
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
let HOST = 'http://localhost:1225'
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.store("dictionary", {
|
||||
search_results: {
|
||||
terms: 1,
|
||||
results: [
|
||||
{
|
||||
lemma_name: "wiigel",
|
||||
word_forms: [
|
||||
{
|
||||
word_form_id: 1262,
|
||||
word_form: "wijgeu",
|
||||
lect: { name: "ArekaDareka" },
|
||||
},
|
||||
{
|
||||
word_form_id: 1263,
|
||||
word_form: "wigl",
|
||||
lect: { name: "Anott" },
|
||||
},
|
||||
{
|
||||
word_form_id: 1264,
|
||||
word_form: "wijev",
|
||||
lect: { name: "Djin" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
prefs: {
|
||||
colorSidebarBg: "#000",
|
||||
colorMainBg: "#eee",
|
||||
colorCardBg: "#fff",
|
||||
colorPrimary: "#0bf",
|
||||
colorSecondary: "#08e",
|
||||
colorText: "#333",
|
||||
colorTextSecondary: "#555",
|
||||
},
|
||||
|
||||
update_css_variables() {
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-sidebar-bg",
|
||||
this.prefs.colorSidebarBg,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-main-bg",
|
||||
this.prefs.colorMainBg,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-card-bg",
|
||||
this.prefs.colorCardBg,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-primary",
|
||||
this.prefs.colorPrimary,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-secondary",
|
||||
this.prefs.colorSecondary,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-text",
|
||||
this.prefs.colorText,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-text-secondary",
|
||||
this.prefs.colorTextSecondary,
|
||||
);
|
||||
},
|
||||
|
||||
load_preferences() {
|
||||
const saved_prefs = localStorage.getItem("prefs");
|
||||
if (saved_prefs) {
|
||||
this.prefs = { ...this.prefs, ...JSON.parse(saved_prefs) };
|
||||
this.update_css_variables();
|
||||
}
|
||||
},
|
||||
|
||||
save_preferences() {
|
||||
localStorage.setItem("prefs", JSON.stringify(this.prefs));
|
||||
this.update_css_variables();
|
||||
},
|
||||
|
||||
async fetch_all_lects() {
|
||||
try {
|
||||
const res = await axios.get(`${HOST}/lects`);
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async post_lect(lect_name) {
|
||||
try {
|
||||
const res = await axios.post(`${HOST}/lect`, {
|
||||
lect_name,
|
||||
});
|
||||
return res.data.lect;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async fetch_all_terms(search_term) {
|
||||
try {
|
||||
const res = await axios.get(`${HOST}/search`, {
|
||||
params: { search_term: search_term },
|
||||
});
|
||||
this.search_results = res.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async fetch_one_lemma_detail(lemma_name) {
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`${HOST}/lemma-detail`,
|
||||
{ params: { lemma_name } },
|
||||
);
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async put_definition(definition_id, definition_text, lemma_name) {
|
||||
try {
|
||||
const res = await axios.put(
|
||||
`${HOST}/definition`,
|
||||
{ definition_id, definition_text, lemma_name },
|
||||
);
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async delete_definition(definition_id, definition_text) {
|
||||
if (
|
||||
window.confirm(`Du keshite imi ${definition_id}.\nPravda?`)
|
||||
) {
|
||||
try {
|
||||
const res = await axios.delete(
|
||||
`${HOST}/definition/${definition_id}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async put_example(example_id, example_text, lemma_name) {
|
||||
try {
|
||||
const res = await axios.put(`${HOST}/example`, {
|
||||
example_id,
|
||||
example_text,
|
||||
lemma_name,
|
||||
});
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async delete_example(example_id, example_text) {
|
||||
if (
|
||||
window.confirm(`Du keshite tato ${example_id}.\nPravda?`)
|
||||
) {
|
||||
try {
|
||||
const res = await axios.delete(
|
||||
`${HOST}/example/${example_id}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async put_word_form_text(word_form_id, word_form_text) {
|
||||
try {
|
||||
const res = await axios.put(`${HOST}/word-form`, {
|
||||
word_form_id,
|
||||
word_form_text,
|
||||
});
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async post_new_word_form(lemma_name, lect_name, word_form_text) {
|
||||
if(!lemma_name || !lect_name || !word_form_text) {
|
||||
console.error(`Missing parameter:\n${JSON.stringify({
|
||||
lemma_name, lect_name, word_form_text
|
||||
})}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${HOST}/word-form`,
|
||||
{ lemma_name, lect_name, word_form_text },
|
||||
);
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async post_lemma(lect_name, word_form_text){
|
||||
/* Accepts a new word form for a lemma and the corresponding lect, initializing with the provided form. */
|
||||
try {
|
||||
const res = await axios.post(
|
||||
`${HOST}/lemma`,
|
||||
{ lect_name, word_form_text },
|
||||
);
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
async delete_word_form(word_form_id, word_form_text) {
|
||||
if (
|
||||
window.confirm(`Du keshite kofal ${word_form_text}.\nPravda?`)
|
||||
) {
|
||||
try {
|
||||
const res = await axios.delete(
|
||||
`${HOST}/word-form/${word_form_id}`,
|
||||
);
|
||||
return res.data.lemma_detail;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
filter_results(search_term) {
|
||||
if (!search_term) return this.search_results.results;
|
||||
return this.search_results.results.filter((item) =>
|
||||
item.word_forms
|
||||
.map((wf) => wf.word_form.toLowerCase())
|
||||
.join()
|
||||
.includes(search_term.toLowerCase()),
|
||||
);
|
||||
},
|
||||
|
||||
md(text){
|
||||
console.debug(marked.parse(text));
|
||||
return DOMPurify.sanitize(marked.parse(text), {ALLOWED_TAGS: ['br', 'em', 'strong', 'code', '#text']});
|
||||
},
|
||||
});
|
||||
});
|
||||
637
apps/ktb-static/search.html
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dictionary Cards</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@18.0.6/lib/marked.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"></script>
|
||||
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||
<script src="script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
class="app-container"
|
||||
@alpine:init="$store.dictionary.load_preferences()"
|
||||
x-data="{
|
||||
search_term: '',
|
||||
showPrefs: false,
|
||||
lects: [],
|
||||
current_result_page: 0,
|
||||
results_per_page: 30,
|
||||
new_lemma_form_open: false,
|
||||
md(text){
|
||||
return $store.dictionary.md(text);
|
||||
},
|
||||
async put_word_form_text(word_form_id, word_form_text){
|
||||
$store.dictionary.put_word_form_text(word_form_id, word_form_text).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
async post_new_word_form(lemma_name, lect_name, word_form_text){
|
||||
$store.dictionary.post_new_word_form(lemma_name, lect_name, word_form_text).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
async delete_word_form(word_form_id, word_form_text){
|
||||
$store.dictionary.delete_word_form(word_form_id, word_form_text).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
async put_definition(definition_id, definition_text){
|
||||
$store.dictionary.put_definition(definition_id, definition_text, this.lemma_detail?.lemma_name).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
async delete_definition(definition_id, definition_text){
|
||||
$store.dictionary.delete_definition(definition_id, definition_text).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
async put_example(example_id, example_text){
|
||||
$store.dictionary.put_example(example_id, example_text, this.lemma_detail?.lemma_name).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
async post_lemma(lect_name, word_form_text){
|
||||
$store.dictionary.post_lemma(lect_name, word_form_text).then(lemma=>console.info(JSON.stringify(lemma)));
|
||||
},
|
||||
async delete_example(example_id, example_text){
|
||||
$store.dictionary.delete_example(example_id, example_text).then(()=>{
|
||||
this.load_detail();
|
||||
});
|
||||
},
|
||||
}"
|
||||
x-init="$store.dictionary.fetch_all_lects().then((lects_response)=>{
|
||||
if(!!lects_response.lects) {
|
||||
lects = lects_response.lects;
|
||||
}
|
||||
});">
|
||||
<aside class="sidebar">
|
||||
<div class="preferences-pane">
|
||||
<button
|
||||
@click="showPrefs = !showPrefs"
|
||||
class="prefs-toggle is-fullwidth">
|
||||
Sejenazma
|
||||
</button>
|
||||
|
||||
<div class="prefs-content" x-show="showPrefs">
|
||||
<div class="prefs-section">
|
||||
<h3>Sejenazma</h3>
|
||||
<label>
|
||||
Hinafarge fu ljevatel:
|
||||
<input
|
||||
type="color"
|
||||
x-model="$store.dictionary.prefs.colorSidebarBg"
|
||||
@input="$store.dictionary.save_preferences()" />
|
||||
</label>
|
||||
<label>
|
||||
Hinafarge fu glavna lehtia:
|
||||
<input
|
||||
type="color"
|
||||
x-model="$store.dictionary.prefs.colorMainBg"
|
||||
@input="$store.dictionary.save_preferences()" />
|
||||
</label>
|
||||
<label>
|
||||
Hinafarge fu kofuga:
|
||||
<input
|
||||
type="color"
|
||||
x-model="$store.dictionary.prefs.colorCardBg"
|
||||
@input="$store.dictionary.save_preferences()" />
|
||||
</label>
|
||||
<label>
|
||||
Viktifarge:
|
||||
<input
|
||||
type="color"
|
||||
x-model="$store.dictionary.prefs.colorPrimary"
|
||||
@input="$store.dictionary.save_preferences()" />
|
||||
</label>
|
||||
<label>
|
||||
Nisvikti farge:
|
||||
<input
|
||||
type="color"
|
||||
x-model="$store.dictionary.prefs.colorSecondary"
|
||||
@input="$store.dictionary.save_preferences()" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="top-bar sticky top">
|
||||
<div class="search-bar">
|
||||
<button
|
||||
@click="$store.dictionary.fetch_all_terms(search_term)">
|
||||
Zuha
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
x-model="search_term"
|
||||
@keydown.enter="$store.dictionary.fetch_all_terms(search_term)"
|
||||
placeholder="Tasta ko(tel) her..." />
|
||||
<button
|
||||
class="hollow primary"
|
||||
@click="new_lemma_form_open = true">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-row"
|
||||
x-data="{
|
||||
page_count: 1,
|
||||
item_count: 0
|
||||
}"
|
||||
x-effect="current_result_page = Math.min(current_result_page, page_count-1); page_count = Math.ceil($store.dictionary.search_results.terms/results_per_page); item_count = $store.dictionary.search_results.terms;">
|
||||
<button
|
||||
class="hollow primary"
|
||||
:disabled="!(item_count > results_per_page && current_result_page > 0)"
|
||||
@click="current_result_page-=1">
|
||||
<strong>←</strong>
|
||||
</button>
|
||||
<div
|
||||
x-text="`${current_result_page + 1} / ${page_count}`"></div>
|
||||
<button
|
||||
class="hollow primary"
|
||||
:disabled="!(item_count > results_per_page && current_result_page+1 < page_count)"
|
||||
@click="current_result_page+=1">
|
||||
<strong>→</strong>
|
||||
</button>
|
||||
<select x-model="results_per_page">
|
||||
<option value="15">15</option>
|
||||
<option value="30">30</option>
|
||||
<option value="60">60</option>
|
||||
<option value="120">120</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards-container">
|
||||
<template
|
||||
x-for="item in $store.dictionary.filter_results(search_term).slice(current_result_page*results_per_page, Math.min((current_result_page+1)*results_per_page, $store.dictionary.search_results.terms))"
|
||||
:key="item.lemma_name">
|
||||
<div
|
||||
class="card"
|
||||
:class="expanded?'is-expanded':''"
|
||||
@click.self="toggle_expand()"
|
||||
x-data="{
|
||||
expanded: false,
|
||||
adding_definition: false,
|
||||
adding_example: false,
|
||||
adding_media: false,
|
||||
example_text: '',
|
||||
definition_text: '',
|
||||
lemma_detail: null,
|
||||
|
||||
async toggle_expand() {
|
||||
if(!this.lemma_detail){
|
||||
/* If there isn't a lemma detail loaded yet, get it then expand. */
|
||||
await this.load_detail();
|
||||
|
||||
if(this.lemma_detail) {
|
||||
this.expanded = true;
|
||||
} else {
|
||||
alert(`Failed to load lemma ${item.lemma_name}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* If there is content, simply toggle the state of the card */
|
||||
this.expanded = !this.expanded;
|
||||
},
|
||||
async load_detail(){
|
||||
this.lemma_detail = await $store.dictionary.fetch_one_lemma_detail(item.lemma_name);
|
||||
}
|
||||
}">
|
||||
<h3 x-text="item.lemma_name"></h3>
|
||||
<p class="columned">
|
||||
<template x-for="wf in item.word_forms">
|
||||
<span
|
||||
x-text="wf.word_form + ' '"
|
||||
:title="wf.lect.name">
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="is-fullwidth dropdown"
|
||||
@click.self="toggle_expand()">
|
||||
<span x-show="!expanded">v</span>
|
||||
<span x-show="expanded">^</span>
|
||||
</button>
|
||||
|
||||
<div x-show="expanded && lemma_detail">
|
||||
<!-- wfs -->
|
||||
<h4>Trofal</h4>
|
||||
<table class="is-fullwidth">
|
||||
<tbody>
|
||||
<template
|
||||
x-for="wf2 in (lemma_detail?.word_forms ?? [])">
|
||||
<tr
|
||||
x-data="{
|
||||
editing:false,
|
||||
edited_text: wf2.word_form
|
||||
}">
|
||||
<td
|
||||
style="width: 8em"
|
||||
x-text="wf2 ? wf2.lect.name : '(empty)'"></td>
|
||||
<td>
|
||||
<span
|
||||
x-text="wf2 ? wf2.word_form : '(empty)'"
|
||||
@click="editing = true"
|
||||
x-show="!editing"></span>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
class="is-fullwidth"
|
||||
@click.outside="editing = false"
|
||||
x-show="editing"
|
||||
x-model="edited_text" />
|
||||
</td>
|
||||
<td style="width: 5em">
|
||||
<div class="float-right">
|
||||
<button
|
||||
class="tiny hollow primary"
|
||||
x-show="!editing"
|
||||
title="kawari"
|
||||
@click="editing = true">
|
||||
~
|
||||
</button>
|
||||
<button
|
||||
class="tiny hollow delete"
|
||||
x-show="!editing"
|
||||
title="keshite"
|
||||
@click="await delete_word_form(wf2.word_form_id, wf2.word_form)">
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
class="tiny edit"
|
||||
x-show="editing"
|
||||
title="jame"
|
||||
@click="editing = false">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="tiny"
|
||||
x-show="editing"
|
||||
title="antaa"
|
||||
@click="await put_word_form_text(wf2.word_form_id, edited_text); editing = false">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
|
||||
<!-- Add new wf -->
|
||||
<tfoot>
|
||||
<tr
|
||||
x-data="{
|
||||
adding_word_form:false,
|
||||
new_word_form_text:'',
|
||||
selected_lect:'',
|
||||
adding_lect:false,
|
||||
new_lect_name:''
|
||||
}">
|
||||
<td
|
||||
colspan="3"
|
||||
x-show="!adding_word_form">
|
||||
<button
|
||||
class="is-fullwidth hollow primary"
|
||||
@click="adding_word_form = !adding_word_form">
|
||||
+
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<td
|
||||
colspan="3"
|
||||
x-show="adding_word_form"
|
||||
style="width: 12em">
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2em;
|
||||
justify-content: space-between;
|
||||
">
|
||||
<div>
|
||||
<select
|
||||
name="vilgovor"
|
||||
x-model="selected_lect"
|
||||
x-show="!adding_lect">
|
||||
<option
|
||||
value=""
|
||||
selected
|
||||
disabled
|
||||
hidden>
|
||||
Sentaku...
|
||||
</option>
|
||||
<template
|
||||
x-for="lect in lects">
|
||||
<option
|
||||
:value="lect.name"
|
||||
x-text="lect.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
x-show="adding_lect"
|
||||
x-model="new_lect_name" />
|
||||
<button
|
||||
class="float-right tiny primary"
|
||||
x-show="!adding_lect"
|
||||
@click="adding_lect = true"
|
||||
title="neo govor">
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="float-right tiny edit"
|
||||
x-show="adding_lect"
|
||||
title="jame"
|
||||
@click="adding_lect = false">
|
||||
/
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
x-model="new_word_form_text" />
|
||||
</div>
|
||||
<div class="float-right">
|
||||
<button
|
||||
class="tiny edit"
|
||||
x-show="adding_word_form"
|
||||
title="jame"
|
||||
@click="adding_word_form = false">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="tiny"
|
||||
x-show="adding_word_form"
|
||||
title="antaa"
|
||||
:disabled="!(selected_lect || new_lect_name)"
|
||||
@click="await post_new_word_form(item.lemma_name, adding_lect ? new_lect_name : selected_lect, new_word_form_text); adding_word_form = false">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h4>Imi</h4>
|
||||
<ul>
|
||||
<template
|
||||
x-for="def in (lemma_detail?.definitions ?? [])">
|
||||
<li
|
||||
x-data="{
|
||||
editing: false,
|
||||
edited_text: def.definition_text.toString()
|
||||
}"
|
||||
@click.outside="editing = false">
|
||||
<span
|
||||
x-show="!editing"
|
||||
x-html="md(def.definition_text)"
|
||||
@click="editing = true"></span>
|
||||
<input
|
||||
type="text"
|
||||
x-show="editing"
|
||||
x-model="edited_text" />
|
||||
<div class="float-right">
|
||||
<button
|
||||
class="tiny hollow primary"
|
||||
title="kawari"
|
||||
x-show="!editing"
|
||||
@click="editing = true">
|
||||
~
|
||||
</button>
|
||||
<button
|
||||
class="tiny edit"
|
||||
title="jame"
|
||||
x-show="editing"
|
||||
@click="editing = false">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="tiny primary"
|
||||
title="antaa"
|
||||
x-show="editing"
|
||||
@click="await put_definition(def.definition_id, edited_text); editing = false">
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="tiny hollow delete"
|
||||
x-show="!editing"
|
||||
title="keshite"
|
||||
@click="await delete_definition(def.definition_id, def.definition_text)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<textarea
|
||||
class="is-fullwidth"
|
||||
x-show="adding_definition"
|
||||
placeholder="Tasta imi..."
|
||||
x-model="definition_text"></textarea>
|
||||
<div class="flex-row">
|
||||
<button
|
||||
class="edit"
|
||||
x-show="adding_definition"
|
||||
@click="adding_definition = false">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
x-show="adding_definition"
|
||||
type="button"
|
||||
@click="put_definition(null, definition_text)
|
||||
.then(()=>{
|
||||
adding_definition = false;
|
||||
definition_text = '';
|
||||
})">
|
||||
Popoczta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="is-fullwidth hollow primary"
|
||||
x-show="!adding_definition"
|
||||
@click="adding_definition = !adding_definition">
|
||||
+
|
||||
</button>
|
||||
|
||||
<h4>Tatoeba</h4>
|
||||
<ul>
|
||||
<template
|
||||
x-for="ex in (lemma_detail?.examples ?? [])">
|
||||
<li
|
||||
x-data="{
|
||||
editing:false,
|
||||
edited_text: ex.example_text.toString()
|
||||
}"
|
||||
@click.outside="editing = false">
|
||||
<span
|
||||
x-show="!editing"
|
||||
x-html="md(ex.example_text)"
|
||||
@click="editing = true"></span>
|
||||
<input
|
||||
type="text"
|
||||
x-show="editing"
|
||||
x-model="edited_text" />
|
||||
<div class="float-right">
|
||||
<button
|
||||
class="tiny hollow primary"
|
||||
x-show="!editing"
|
||||
@click="editing = true"
|
||||
title="kawari">
|
||||
~
|
||||
</button>
|
||||
<button
|
||||
class="tiny edit"
|
||||
x-show="editing"
|
||||
@click="editing = false"
|
||||
title="jame">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="tiny"
|
||||
x-show="editing"
|
||||
type="button"
|
||||
@click="await put_example(ex.example_id, edited_text); editing = false"
|
||||
title="antaa">
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="tiny hollow delete"
|
||||
x-show="!editing"
|
||||
title="keshite"
|
||||
@click="await delete_example(ex.example_id, ex.example_text)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<textarea
|
||||
class="is-fullwidth"
|
||||
x-show="adding_example"
|
||||
placeholder="Tasta tatoeba..."
|
||||
x-model="example_text"></textarea>
|
||||
<button
|
||||
class="edit"
|
||||
x-show="adding_example"
|
||||
@click="adding_example = false">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
x-show="adding_example"
|
||||
type="button"
|
||||
@click="put_example(null, example_text)
|
||||
.then(()=>{
|
||||
adding_example = false;
|
||||
example_text = '';
|
||||
})">
|
||||
Popoczta
|
||||
</button>
|
||||
<button
|
||||
class="is-fullwidth hollow primary"
|
||||
x-show="!adding_example"
|
||||
@click="adding_example = !adding_example">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
x-show="new_lemma_form_open"
|
||||
class="modal"
|
||||
x-data="{
|
||||
adding_word_form:false,
|
||||
adding_lect:false,
|
||||
new_word_form_text:'',
|
||||
selected_lect:'',
|
||||
new_lect_name:''
|
||||
}"
|
||||
@click.self="new_lemma_form_open = false;">
|
||||
<div class="card">
|
||||
<h3>Neo kotoba...</h3>
|
||||
<div class="flex-row">
|
||||
<div>
|
||||
<label>
|
||||
<div>Viljena govor</div>
|
||||
<div>
|
||||
<select
|
||||
name="vilgovor"
|
||||
x-model="selected_lect"
|
||||
x-show="!adding_lect">
|
||||
<option
|
||||
value=""
|
||||
selected
|
||||
disabled
|
||||
hidden>
|
||||
Sentaku...
|
||||
</option>
|
||||
<template x-for="lect in lects">
|
||||
<option
|
||||
:value="lect.name"
|
||||
x-text="lect.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
x-show="adding_lect"
|
||||
x-model="new_lect_name" />
|
||||
<button
|
||||
class="float-right tiny primary"
|
||||
x-show="!adding_lect"
|
||||
@click="adding_lect = true"
|
||||
title="neo govor">
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="float-right tiny edit"
|
||||
x-show="adding_lect"
|
||||
title="jame"
|
||||
@click="adding_lect = false">
|
||||
/
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<div>Kakufal</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
x-model="new_word_form_text" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-top: var(--space-sm)">
|
||||
<button
|
||||
class="edit"
|
||||
@click="new_lemma_form_open = false">
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="post_lemma(new_lect_name?new_lect_name:selected_lect, new_word_form_text)
|
||||
.then(()=>{
|
||||
new_lemma_form_open = false;
|
||||
})">
|
||||
Popoczta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
386
apps/ktb-static/styles.css
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
:root {
|
||||
--color-sidebar-bg: #000;
|
||||
--color-primary: #0bf;
|
||||
--color-secondary: #08e;
|
||||
--color-edit: #fb0;
|
||||
--color-delete: #b10;
|
||||
--color-main-bg: #eee;
|
||||
--color-card-bg: #fff;
|
||||
--color-text: #333;
|
||||
--color-text-secondary: #555;
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
--color-border: #ddd;
|
||||
|
||||
--space-xs: 0.2rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 1.0rem;
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--shadow-sm: 0 1px 2px var(--color-shadow);
|
||||
--shadow-md: 0 2px 4px var(--color-shadow);
|
||||
--shadow-lg: 0 4px 8px var(--color-shadow);
|
||||
}
|
||||
|
||||
/* Small devices (portrait tablets and large phones, 600px and up) */
|
||||
@media (max-width: 600px) {
|
||||
.cards-container {
|
||||
grid-template-columns: 1fr;
|
||||
background-color: #fb0;
|
||||
}
|
||||
|
||||
.is-expanded {
|
||||
grid-row: span 1;
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Verdana', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background-color: var(--color-main-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
border: solid 1px #000;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-secondary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
li {
|
||||
margin-top: calc(var(--space-xs));
|
||||
margin-bottom: calc(var(--space-xs)/2);
|
||||
margin-left: var(--space-md);
|
||||
}
|
||||
|
||||
tbody {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.float-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.align-self-right {
|
||||
align-self: self-end;
|
||||
}
|
||||
|
||||
.tight {
|
||||
padding: 0 var(--space-sm);
|
||||
margin: var(--space-xs);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
background-color: #000000aa;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal .card {
|
||||
max-width: 480px;
|
||||
margin: auto;
|
||||
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: var(--color-card-bg);
|
||||
}
|
||||
|
||||
button.tiny {
|
||||
min-width: 2em;
|
||||
max-width: 2em;
|
||||
text-align: center;
|
||||
padding: 0 var(--space-xs);
|
||||
margin: var(--space-xs);
|
||||
}
|
||||
|
||||
button:disabled{
|
||||
background-color: #aaa;
|
||||
color: #fff;
|
||||
border-color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
gap: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.grow-fill {
|
||||
flex-grow: 100;
|
||||
}
|
||||
|
||||
.edit {
|
||||
background-color: var(--color-edit);
|
||||
}
|
||||
|
||||
.edit:hover {
|
||||
background-color: rgb(187, 159, 0);
|
||||
}
|
||||
|
||||
.delete {
|
||||
background-color: var(--color-delete);
|
||||
}
|
||||
|
||||
.delete:hover {
|
||||
background-color: #f00;
|
||||
}
|
||||
|
||||
.hollow {
|
||||
background-color: #00000000;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.hollow.primary {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hollow.edit {
|
||||
border-color: var(--color-edit);
|
||||
}
|
||||
|
||||
.hollow.delete {
|
||||
border-color: var(--color-delete);
|
||||
}
|
||||
|
||||
.primary:not(.hollow) {
|
||||
background-color: var(--color-primary);
|
||||
color: #000
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
border-radius: 0px;
|
||||
border-style: none;
|
||||
border-top: solid 1px var(--color-text);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
|
||||
}
|
||||
|
||||
.dropdown:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.is-fullwidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.app-container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: clamp(180px, 20%, 250px);
|
||||
background-color: var(--color-sidebar-bg);
|
||||
color: white;
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
resize: horizontal;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Preferences Pane */
|
||||
.preferences-pane {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
|
||||
.prefs-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
width: 100%;
|
||||
padding: var(--space-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prefs-content {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.prefs-content.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.prefs-section {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.prefs-section h3 {
|
||||
margin-bottom: var(--space-xs);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.prefs-section label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Search Bar */
|
||||
|
||||
.search-bar {
|
||||
margin: var(--space-sm);
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
display: inline-block;
|
||||
width: 16rem;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.05s ease;
|
||||
}
|
||||
|
||||
.search-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
display: inline-block;
|
||||
background: var(--color-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.search-bar button:hover {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.cards-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columned {
|
||||
columns: 5em 2;
|
||||
}
|
||||
|
||||
.card {
|
||||
align-self: start;
|
||||
background: var(--color-card-bg);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: transform 0.05s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card.is-expanded {
|
||||
grid-row: span 5;
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
outline: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: clamp(1.1rem, 1.5vw, 1.25rem);
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card h4 {
|
||||
font-size: clamp(1.rem, 1.4vw, 1.1rem);
|
||||
margin-top: var(--space-md);
|
||||
margin-bottom: var(--space-xs);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"@feathersjs/feathers": "^5.0.6",
|
||||
"@google-cloud/local-auth": "^3.0.1",
|
||||
"@repo/common": "workspace:*",
|
||||
"cors": "^2.8.6",
|
||||
"csv-parser": "^3.2.0",
|
||||
"express": "^5.1.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^22.5.1",
|
||||
"tsx": "^4.19.4",
|
||||
|
|
|
|||
|
|
@ -114,5 +114,5 @@ export class PartOfSpeech extends BaseEntity {
|
|||
long_form: string;
|
||||
|
||||
@Column({ nullable: false, unique: true, type: "text" })
|
||||
short_form: string;
|
||||
short_form: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
import "reflect-metadata";
|
||||
import { SAMPLE } from "@repo/common/sample";
|
||||
import express from "express";
|
||||
import crypto from "crypto"
|
||||
import fs from 'fs';
|
||||
;import { appDataSource } from "./config/dbconfig.js";
|
||||
import { Lemma, WordForm, Lect } from "./db/dbmodel.js";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import { appDataSource } from "./config/dbconfig.js";
|
||||
import { Lemma, WordForm, Lect, Definition, Example } from "./db/dbmodel.js";
|
||||
import "@total-typescript/ts-reset";
|
||||
import {
|
||||
Like, In
|
||||
} from "typeorm";
|
||||
import { Like, In } from "typeorm";
|
||||
import cors from "cors";
|
||||
|
||||
const RELOAD_SHEET_ON_START = false;
|
||||
const SOURCE_FILE = 'res/sample.tsv'
|
||||
const SOURCE_FILE = "res/sample.tsv";
|
||||
|
||||
appDataSource
|
||||
.initialize()
|
||||
|
|
@ -28,41 +27,41 @@ function initExpress() {
|
|||
const app = express();
|
||||
const PORT = 1225;
|
||||
|
||||
const lect_repository = appDataSource.getRepository(Lect);
|
||||
const word_form_repository = appDataSource.getRepository(WordForm);
|
||||
const lemma_repository = appDataSource.getRepository(Lemma);
|
||||
app.use(cors(), express.json());
|
||||
|
||||
app.get("/sample", (_req, res) => {
|
||||
res.status(200).send(SAMPLE);
|
||||
});
|
||||
|
||||
app.get("/search", async (req, res) => {
|
||||
const search_term = req.query.search_term?.toString();
|
||||
app.get("/search", async (_req, res) => {
|
||||
const search_term = _req.query.search_term?.toString();
|
||||
|
||||
let word_forms: WordForm[];
|
||||
let lemmas: Lemma[];
|
||||
|
||||
if (!search_term) {
|
||||
return void res.sendStatus(400);
|
||||
lemmas = await Lemma.find({
|
||||
relations: { word_forms: { lect: true } }
|
||||
});
|
||||
} else {
|
||||
word_forms = await WordForm.find({
|
||||
where: { word_form: Like(`%${search_term}%`) },
|
||||
relations: { lemma: true },
|
||||
});
|
||||
|
||||
let lemma_ids = word_forms.map((w) => w.lemma.lemma_name);
|
||||
|
||||
lemmas = await Lemma.find({
|
||||
where: { lemma_name: In(lemma_ids) },
|
||||
relations: { word_forms: { lect: true } },
|
||||
});
|
||||
}
|
||||
|
||||
const word_forms: WordForm[] = await WordForm.find({
|
||||
where:{word_form: Like(`%${search_term}%`)},
|
||||
relations: { lemma: true }
|
||||
});
|
||||
|
||||
let lemma_ids = word_forms.map(w=>w.lemma.lemma_name);
|
||||
|
||||
const lemmas: Lemma[] = await Lemma.find({
|
||||
where: { lemma_name: In(lemma_ids)},
|
||||
relations: { word_forms: { lect: true }}
|
||||
})
|
||||
|
||||
res.status(200).send({
|
||||
terms: lemmas.length,
|
||||
results: lemmas
|
||||
});
|
||||
res.status(200).send({ terms: lemmas.length, results: lemmas });
|
||||
});
|
||||
|
||||
app.get("/lect", async (req, res) => {
|
||||
const name = req.query.name?.toString();
|
||||
app.get("/lect", async (_req, res) => {
|
||||
const name = _req.query.name?.toString();
|
||||
|
||||
if (!name) {
|
||||
return void res.sendStatus(400);
|
||||
|
|
@ -76,11 +75,365 @@ function initExpress() {
|
|||
res.status(200).send({ lect });
|
||||
});
|
||||
|
||||
app.post("/lect", (_req, res) => {
|
||||
const lect_name = _req.query.lect_name?.toString();
|
||||
|
||||
if (!lect_name) {
|
||||
return void res.sendStatus(400);
|
||||
}
|
||||
|
||||
const lect = new Lect();
|
||||
lect.name = lect_name;
|
||||
lect.save().then((lect)=>{
|
||||
res.status(200).send({ lect })
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
app.get("/lects", async (_req, res) => {
|
||||
const lects = await Lect.find();
|
||||
res.status(200).send({
|
||||
lects,
|
||||
res.status(200).send({ lects });
|
||||
});
|
||||
|
||||
app.get("/lemma-detail", async (_req, res) => {
|
||||
const lemma_name = _req.query.lemma_name?.toString();
|
||||
|
||||
const lemma_detail = await Lemma.findOne({
|
||||
where: {
|
||||
lemma_name: lemma_name
|
||||
},
|
||||
relations: {
|
||||
word_forms: {
|
||||
lect: true
|
||||
},
|
||||
examples: true,
|
||||
definitions: true,
|
||||
media: true,
|
||||
parts_of_speech: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).send({ lemma_detail });
|
||||
});
|
||||
|
||||
app.post("/lemma", async (_req, res) => {
|
||||
const lect_name = _req.body.lect_name ?? null;
|
||||
const word_form_text = _req.body.word_form_text?.toString();
|
||||
|
||||
if(!word_form_text || /^\s*$/.test(word_form_text) || !lect_name){
|
||||
console.error(`Error: ${JSON.stringify(_req.body)}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
let word_form = new WordForm();
|
||||
let lemma = new Lemma();
|
||||
|
||||
let lect;
|
||||
try{
|
||||
lect = await Lect.findOne({where:{name:lect_name}});
|
||||
} catch {
|
||||
console.info(`Lect ${lect_name} not found.`);
|
||||
}
|
||||
|
||||
if(!lect) {
|
||||
lect = new Lect();
|
||||
lect.name = lect_name;
|
||||
await lect
|
||||
.save()
|
||||
.then((l)=>{console.info(`Created lect: ${JSON.stringify(l)}`)});
|
||||
}
|
||||
|
||||
|
||||
lemma.lemma_name = word_form_text;
|
||||
|
||||
word_form.word_form = word_form_text;
|
||||
word_form.lemma = lemma;
|
||||
word_form.lect = lect;
|
||||
|
||||
lemma.word_forms = [word_form];
|
||||
|
||||
Lemma.save(lemma);
|
||||
|
||||
let lemma_detail = await Lemma.findOne({
|
||||
where: {
|
||||
lemma_name: word_form.lemma.lemma_name
|
||||
},
|
||||
relations: {
|
||||
word_forms: {
|
||||
lect: true
|
||||
},
|
||||
examples: true,
|
||||
definitions: true,
|
||||
media: true,
|
||||
parts_of_speech: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).send({lemma_detail});
|
||||
});
|
||||
|
||||
app.get("/definition/:definition_id", async (_req, res) =>{
|
||||
const definition_id:number = parseInt(_req.params.definition_id);
|
||||
|
||||
if(!definition_id){
|
||||
console.error(`Error: Could not find word form ${JSON.stringify({definition_id})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
let definition = await Definition.findOne({where:{definition_id}}).then();
|
||||
|
||||
res.status(200).send({definition});
|
||||
});
|
||||
|
||||
app.put("/definition", async (_req, res) => {
|
||||
const definition_text = _req.body.definition_text?.toString();
|
||||
const definition_id = _req.body.definition_id ?? null;
|
||||
const lemma_name = _req.body.lemma_name?.toString();
|
||||
|
||||
if(definition_text == null || lemma_name == null){
|
||||
console.error(`Error: ${JSON.stringify({definition_text:definition_text, lemma_name:lemma_name})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
const lemma = await Lemma.findOne({where:{
|
||||
lemma_name: lemma_name
|
||||
}});
|
||||
|
||||
if(!lemma){
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
let definition:Definition = new Definition();
|
||||
definition.definition_text = definition_text;
|
||||
definition.lemma = lemma;
|
||||
if(definition_id){
|
||||
definition.definition_id = definition_id;
|
||||
}
|
||||
|
||||
Definition.save(definition)
|
||||
|
||||
let lemma_detail = await Lemma.findOne({
|
||||
where: {
|
||||
lemma_name: lemma_name
|
||||
},
|
||||
relations: {
|
||||
word_forms: {
|
||||
lect: true
|
||||
},
|
||||
examples: true,
|
||||
definitions: true,
|
||||
media: true,
|
||||
parts_of_speech: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).send({lemma_detail});
|
||||
});
|
||||
|
||||
app.delete("/definition/:definition_id", async (_req, res) =>{
|
||||
const definition_id:number = parseInt(_req.params.definition_id);
|
||||
|
||||
if(!definition_id){
|
||||
console.error(`Error: Could not find word form ${JSON.stringify({definition_id})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
Definition.delete({definition_id: definition_id});
|
||||
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
/* examples */
|
||||
|
||||
app.get("/example/:example_id", async (_req, res) =>{
|
||||
const example_id:number = parseInt(_req.params.example_id);
|
||||
|
||||
if(!example_id){
|
||||
console.error(`Error: Could not find word form ${JSON.stringify({example_id})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
let example = await Example.findOne({where:{example_id}}).then();
|
||||
|
||||
res.status(200).send({definition: example});
|
||||
});
|
||||
|
||||
app.put("/example", async (_req, res) => {
|
||||
const example_text = _req.body.example_text?.toString();
|
||||
const example_id = _req.body.example_id ?? null;
|
||||
const lemma_name = _req.body.lemma_name?.toString();
|
||||
|
||||
if(!example_text || !lemma_name){
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
const lemma = await Lemma.findOne({where:{
|
||||
lemma_name
|
||||
}});
|
||||
|
||||
if(!lemma){
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
var example:Example = new Example();
|
||||
example.example_text = example_text;
|
||||
example.lemma = lemma;
|
||||
if(example_id){
|
||||
example.example_id = example_id;
|
||||
}
|
||||
|
||||
|
||||
Example.save(example)
|
||||
|
||||
var lemma_detail = await Lemma.findOne({
|
||||
where: {
|
||||
lemma_name: lemma_name
|
||||
},
|
||||
relations: {
|
||||
word_forms: {
|
||||
lect: true
|
||||
},
|
||||
examples: true,
|
||||
definitions: true,
|
||||
media: true,
|
||||
parts_of_speech: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).send({lemma_detail});
|
||||
});
|
||||
|
||||
app.delete("/example/:example_id", async (_req, res) =>{
|
||||
const example_id:number = parseInt(_req.params.example_id);
|
||||
|
||||
if(!example_id){
|
||||
console.error(`Error: Could not find word form ${JSON.stringify({example_id:example_id})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
Example.delete({example_id: example_id});
|
||||
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
/* word forms */
|
||||
|
||||
app.put("/word-form", async (_req, res) => {
|
||||
const word_form_text = _req.body.word_form_text?.toString();
|
||||
const word_form_id = _req.body.word_form_id ?? null;
|
||||
const lemma_name = _req.body.lemma_name ?? null;
|
||||
|
||||
if(!word_form_text || /^\s*$/.test(word_form_text) || !word_form_id){
|
||||
console.error(`Error: ${JSON.stringify({word_form_text:word_form_text, word_form_id:word_form_id})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
let word_form = await WordForm.findOne({where:{
|
||||
word_form_id: word_form_id
|
||||
}, relations:{
|
||||
lemma: true
|
||||
}});
|
||||
|
||||
if(!word_form){
|
||||
if(lemma_name){
|
||||
word_form = new WordForm();
|
||||
let lemma = await Lemma.findOne({where:{lemma_name}});
|
||||
}
|
||||
console.error(`Failed to find word form with ID ${word_form_id}`)
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
|
||||
word_form.word_form = word_form_text;
|
||||
|
||||
WordForm.save(word_form);
|
||||
|
||||
let lemma_detail = await Lemma.findOne({
|
||||
where: {
|
||||
lemma_name: word_form.lemma.lemma_name
|
||||
},
|
||||
relations: {
|
||||
word_forms: {
|
||||
lect: true
|
||||
},
|
||||
examples: true,
|
||||
definitions: true,
|
||||
media: true,
|
||||
parts_of_speech: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).send({lemma_detail});
|
||||
});
|
||||
|
||||
app.post("/word-form", async (_req, res) => {
|
||||
const lemma_name = _req.body.lemma_name ?? null;
|
||||
const lect_name = _req.body.lect_name ?? null;
|
||||
const word_form_text = _req.body.word_form_text?.toString();
|
||||
|
||||
if(!word_form_text || /^\s*$/.test(word_form_text) || !lect_name || !lemma_name){
|
||||
console.error(`Error: ${JSON.stringify(_req.body)}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
let word_form = new WordForm();
|
||||
let lemma = await Lemma.findOne({where:{lemma_name}});
|
||||
let lect;
|
||||
try{
|
||||
lect = await Lect.findOne({where:{name:lect_name}});
|
||||
} catch {
|
||||
console.info(`Lect ${lect_name} not found.`);
|
||||
}
|
||||
|
||||
if(!lemma){
|
||||
console.error(`Error: ${JSON.stringify({lect:lect, lemma:lemma})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
if(!lect) {
|
||||
lect = new Lect();
|
||||
lect.name = lect_name;
|
||||
await lect
|
||||
.save()
|
||||
.then((l)=>{console.info(`Created lect: ${JSON.stringify(l)}`)});
|
||||
}
|
||||
|
||||
|
||||
word_form.word_form = word_form_text;
|
||||
word_form.lemma = lemma;
|
||||
word_form.lect = lect;
|
||||
|
||||
WordForm.save(word_form);
|
||||
|
||||
let lemma_detail = await Lemma.findOne({
|
||||
where: {
|
||||
lemma_name: word_form.lemma.lemma_name
|
||||
},
|
||||
relations: {
|
||||
word_forms: {
|
||||
lect: true
|
||||
},
|
||||
examples: true,
|
||||
definitions: true,
|
||||
media: true,
|
||||
parts_of_speech: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(200).send({lemma_detail});
|
||||
});
|
||||
|
||||
app.delete("/word-form/:word_form_id", async (_req, res) =>{
|
||||
const word_form_id:number = parseInt(_req.params.word_form_id);
|
||||
|
||||
if(!word_form_id){
|
||||
console.error(`Error: Could not find word form ${JSON.stringify({word_form_id:word_form_id})}`);
|
||||
return void res.status(400).send();
|
||||
}
|
||||
|
||||
WordForm.delete({word_form_id: word_form_id});
|
||||
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
|
@ -98,18 +451,17 @@ async function loadSheet() {
|
|||
await lemma_repository.clear();
|
||||
await lect_repository.clear();
|
||||
|
||||
|
||||
const rawData: string = fs.readFileSync(SOURCE_FILE, 'utf8');
|
||||
const rows: string[] = rawData.split('\n');
|
||||
const rawData: string = fs.readFileSync(SOURCE_FILE, "utf8");
|
||||
const rows: string[] = rawData.split("\n");
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
console.error("No data found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lect_names = rows.shift()?.split('\t');
|
||||
const lect_names = rows.shift()?.split("\t");
|
||||
|
||||
const keys = rows.map(row=>row.split('\t')[0]?.split(';')[0]);
|
||||
const keys = rows.map((row) => row.split("\t")[0]?.split(";")[0]);
|
||||
console.log(keys);
|
||||
|
||||
if (keys.length != rows.length) {
|
||||
|
|
@ -128,27 +480,27 @@ async function loadSheet() {
|
|||
let l = new Lect();
|
||||
l.name = lect;
|
||||
l.save();
|
||||
lects.push(l)
|
||||
lects.push(l);
|
||||
console.log(l);
|
||||
}
|
||||
|
||||
const lemmas = Array<Lemma>();
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]?.split('\t');
|
||||
const row = rows[i]?.split("\t");
|
||||
const lect_name = lect_names[i];
|
||||
|
||||
if(!lect_name){
|
||||
if (!lect_name) {
|
||||
console.error("No lect name");
|
||||
break;
|
||||
}
|
||||
|
||||
if(!row){
|
||||
if (!row) {
|
||||
console.error("Row doesn't exist");
|
||||
continue;
|
||||
}
|
||||
|
||||
if(row.length !== lect_names.length){
|
||||
|
||||
if (row.length !== lect_names.length) {
|
||||
console.error("Mismatched row size");
|
||||
continue;
|
||||
}
|
||||
|
|
@ -175,10 +527,10 @@ async function loadSheet() {
|
|||
const lect = lects[j];
|
||||
|
||||
if (
|
||||
cell === null ||
|
||||
cell === undefined ||
|
||||
(typeof cell === "string" && cell.length === 0) ||
|
||||
!lect
|
||||
cell === null
|
||||
|| cell === undefined
|
||||
|| (typeof cell === "string" && cell.length === 0)
|
||||
|| !lect
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import ts from "typescript-eslint";
|
||||
import vue from "eslint-plugin-vue";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import vueParser from "vue-eslint-parser";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
ts.configs.strictTypeChecked,
|
||||
...vue.configs["flat/essential"],
|
||||
],
|
||||
files: ["./src/**/*.{js,ts,vue}"],
|
||||
plugins: { vue },
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: globals.browser,
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
parser: ts.parser,
|
||||
extraFileExtensions: [".vue"],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"vue/no-restricted-html-elements": [
|
||||
"error",
|
||||
{
|
||||
element: ["a", "RouterLink"],
|
||||
message: "Use <SmartLink> instead.",
|
||||
},
|
||||
{ element: ["i18n-t"], message: "Use <RichTemplate> instead." },
|
||||
{
|
||||
element: ["RichTemplateParts"],
|
||||
message:
|
||||
"Do not use the internal <RichTemplateParts> component. Use <RichTemplate> instead.",
|
||||
},
|
||||
],
|
||||
// allow interfaces to only extend another interface without adding properties
|
||||
// good for aliasing more complex types
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{ allowInterfaces: "with-single-extends" },
|
||||
],
|
||||
"vue/no-ref-object-reactivity-loss": ["error"],
|
||||
"@typescript/no-unnecessary-conditions": [
|
||||
"error",
|
||||
{ allowConstantLoopConditions: "only-allowed-literals" },
|
||||
],
|
||||
},
|
||||
},
|
||||
// disable multi-word-component-names for unplugin-vue-router
|
||||
{
|
||||
files: ["src/pages/**/*.vue"],
|
||||
rules: { "vue/multi-word-component-names": "off" },
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="has-navbar-fixed-top">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/viossa_circle.svg" />
|
||||
<link href='https://cdn.boxicons.com/fonts/basic/boxicons.min.css' rel='stylesheet'>
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Viossa.net</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5498
apps/vdn-static/package-lock.json
generated
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"name": "@repo/vdn-static",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluent/bundle": "^0.19.1",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"@types/node": "^22.15.31",
|
||||
"@vueuse/components": "^13.3.0",
|
||||
"@vueuse/core": "^13.3.0",
|
||||
"arktype": "^2.1.29",
|
||||
"axios": "^1.11.0",
|
||||
"bulma": "^1.0.4",
|
||||
"tailwindcss": "^4.1.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.3",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@repo/common": "workspace:*",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"globals": "^17.3.0",
|
||||
"prettier": "^3.5.3",
|
||||
"sass": "^1.87.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"unplugin-vue-router": "^0.12.0",
|
||||
"vite": "^6.3.5",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^2.2.8"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0"
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.1" id="Layer_1" x="0px" y="0px" width="144.0" height="144.0" viewBox="56.0 56.0 144.0 144.0" style="enable-background:new 0 0 256 256;" xml:space="preserve">
|
||||
<ns0:style type="text/css">
|
||||
.Drop_x0020_Shadow{fill:none;}
|
||||
.Thick_x0020_Blue_x0020_Neon{fill:none;stroke:#0073BC;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.Illuminating_x0020_Aqua{fill:url(#SVGID_1_);stroke:#FFFFFF;stroke-width:0.25;stroke-miterlimit:1;}
|
||||
|
||||
.Black_x0020_Highlight{fill:url(#SVGID_00000121249889455812460230000004963949080483821184_);stroke:#FFFFFF;stroke-width:0.362861;stroke-miterlimit:1;}
|
||||
.Bugaboo_GS{fill-rule:evenodd;clip-rule:evenodd;fill:#FFDD00;}
|
||||
.st0{fill:#4DABF7;}
|
||||
.st1{fill:#F8F9FA;}
|
||||
</ns0:style>
|
||||
<ns0:linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="6.123234e-17" y2="-1">
|
||||
<ns0:stop offset="0" style="stop-color:#1D59F4" />
|
||||
<ns0:stop offset="0.617977" style="stop-color:#2D65EE" />
|
||||
<ns0:stop offset="0.629213" style="stop-color:#3864F3" />
|
||||
<ns0:stop offset="0.983146" style="stop-color:#00DDFC" />
|
||||
</ns0:linearGradient>
|
||||
<ns0:linearGradient id="SVGID_00000029744916847460459310000011004185732043246012_" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="6.123234e-17" y2="-1">
|
||||
<ns0:stop offset="0" style="stop-color:#060606" />
|
||||
<ns0:stop offset="0.617977" style="stop-color:#000000" />
|
||||
<ns0:stop offset="0.629213" style="stop-color:#000000" />
|
||||
<ns0:stop offset="0.983146" style="stop-color:#000000" />
|
||||
</ns0:linearGradient>
|
||||
<ns0:circle class="st0" cx="128" cy="128" r="72" />
|
||||
<ns0:path class="st1" d="M187.6656494,101.3799362h1.3769073l-0.4589691,1.1474228l-2.2948303,2.0653458l-3.2127686,2.7537994 l-3.4422455,2.983284l-3.2127686,2.5243149l-2.9832916,2.0653534l-11.4741516,6.8844986l-4.8191528,3.67173l-4.5896606,3.4422531 l-3.9012299,2.7537994l-2.2948303,2.7537994l-1.6063843,2.5243073l1.6063843-0.4589691h0.9179382l-0.4589691,1.1474152 l-1.3768921,1.6063843l-2.9832916,2.2948456l-0.917923,0.688446l-0.6884613,5.5075989l-1.6063843,3.2127686l-2.5243073,2.7537994 l-2.0653534,0.917923h-2.2948303l-2.2948303-0.917923l-1.8358765-2.0653534l-2.7537918-4.5896606l-2.0653534-2.2948456 l-2.2948303-1.6063843l-3.67173-1.8358612l-2.983284-0.9179382l-9.8677826-1.606369l-8.9498444-2.0653534l-15.8343506-3.9012146 l-4.8191452-1.6063843l-3.6717377-1.8358765l-3.2127609-2.0653381l-1.1474228-1.3768997l0.2294846-0.2294846h2.294838 l8.7203598,2.7537994l9.8677826,2.2948303l5.7370834,0.9179382l12.1626129,0.2294769l5.2781143,0.688446l5.5075989,1.1474152 l3.9012146,1.3769073l2.983284,1.6063843l2.5243149,1.8358612l1.1474228,0.688446h2.2948303l5.2781067-0.917923l2.9832916-0.2294922 l1.6063843-0.688446l2.5243073-4.3601837l2.2948456-3.2127686l2.7537994-2.7537918l3.9012146-2.983284l5.7370758-3.4422531 l5.5075989-2.7537994l4.3601837-2.0653458l5.2781219-3.4422531l3.6717224-2.5243149l3.6717377-2.7537994l2.9832764-2.2948303 l3.9012146-2.5243149L187.6656494,101.3799362z" />
|
||||
</ns0:svg>
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import "./assets/style.scss";
|
||||
import { computed, ref, type Ref } from "vue";
|
||||
import LocalePicker from "./components/organisms/LocalePicker.vue";
|
||||
import { vOnClickOutside } from "@vueuse/components";
|
||||
import { useRouter } from "vue-router";
|
||||
import SmartLink from "./components/atoms/SmartLink.vue";
|
||||
import type { SmartDest } from "./utils/smart-dest";
|
||||
import { useLocale, type Locale } from "./new-i18n";
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
const burgerOpen: Ref<boolean> = ref<boolean>(false);
|
||||
|
||||
const toggleBurger = (): void => {
|
||||
burgerOpen.value = !burgerOpen.value;
|
||||
};
|
||||
|
||||
const closeBurger = (): void => {
|
||||
burgerOpen.value = false;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
router.beforeEach(() => {
|
||||
closeBurger();
|
||||
});
|
||||
|
||||
interface NavbarItem {
|
||||
to: SmartDest;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NAVBAR_ITEM_ORDER = [
|
||||
"whatIsViossa",
|
||||
"resources",
|
||||
"kotoba",
|
||||
] as const satisfies (keyof Locale["navbar"])[];
|
||||
|
||||
const navbarItems = computed(() =>
|
||||
NAVBAR_ITEM_ORDER.map((id): NavbarItem => {
|
||||
const label = locale.value.navbar[id]();
|
||||
|
||||
const to = ((): SmartDest => {
|
||||
switch (id) {
|
||||
case "whatIsViossa": {
|
||||
return { type: "internal", internal: { route: "/" } };
|
||||
}
|
||||
case "resources": {
|
||||
return {
|
||||
type: "internal",
|
||||
internal: { route: "/resources" },
|
||||
};
|
||||
}
|
||||
case "kotoba": {
|
||||
return { type: "internal", internal: { route: "/kotoba" } };
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return { to, label };
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col" v-on-click-outside="closeBurger">
|
||||
<!-- Main application wrapper -->
|
||||
<nav
|
||||
class="navbar is-fixed-top"
|
||||
role="navigation"
|
||||
aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<SmartLink
|
||||
class="navbar-item has-text-weight-bold"
|
||||
:to="{ type: 'internal', internal: { route: '/' } }">
|
||||
<img src="@/assets/ViossaFlagRect.svg" alt="" />
|
||||
</SmartLink>
|
||||
|
||||
<div class="navbar-item is-hidden-desktop">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleBurger()"
|
||||
:class="`button is-link is-hoverable is-hidden-desktop ${burgerOpen ? 'is-active' : ''}`"
|
||||
aria-label="menu"
|
||||
:aria-expanded="`${burgerOpen ? 'true' : 'false'}`">
|
||||
<span class="bx bx-burger"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="`navbar-menu ${burgerOpen ? 'is-active' : ''}`">
|
||||
<div class="navbar-start">
|
||||
<SmartLink
|
||||
v-for="(item, index) in navbarItems"
|
||||
:key="index"
|
||||
class="navbar-item"
|
||||
:to="item.to"
|
||||
>{{ item.label }}
|
||||
</SmartLink>
|
||||
<LocalePicker class="navbar-item" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<RouterView />
|
||||
</div>
|
||||
</template>
|
||||
4
apps/vdn-static/src/assets.d.ts
vendored
|
|
@ -1,4 +0,0 @@
|
|||
declare module "*.ftl" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="360mm"
|
||||
height="360mm"
|
||||
viewBox="0 0 360 360"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||
sodipodi:docname="viossaFlag.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.7299663"
|
||||
inkscape:cx="86.126161"
|
||||
inkscape:cy="613.87795"
|
||||
inkscape:window-width="2400"
|
||||
inkscape:window-height="1411"
|
||||
inkscape:window-x="2391"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2"><inkscape:grid
|
||||
type="xygrid"
|
||||
id="grid302" /></sodipodi:namedview><defs
|
||||
id="defs2" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 2"
|
||||
transform="translate(134.0933,87.179113)"><path
|
||||
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.0289973;stroke-miterlimit:22.2;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path6216"
|
||||
sodipodi:type="arc"
|
||||
sodipodi:cx="45.906708"
|
||||
sodipodi:cy="92.820892"
|
||||
sodipodi:rx="180"
|
||||
sodipodi:ry="180"
|
||||
sodipodi:start="4.6934522"
|
||||
sodipodi:end="4.6302294"
|
||||
sodipodi:open="true"
|
||||
sodipodi:arc-type="arc"
|
||||
d="M 42.498291,-87.146834 A 180,180 0 0 1 225.79809,86.568504 180,180 0 0 1 55.001506,272.59098 180,180 0 0 1 -133.69718,104.75583 180,180 0 0 1 31.134615,-86.571932"
|
||||
inkscape:export-filename="..\Finished\ViossaFlagCirc3_2ratio.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" /><g
|
||||
id="g5819"
|
||||
transform="matrix(0.93405356,0,0,0.93405356,-53.016663,-34.779538)"><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.399354;stroke-miterlimit:22.2"
|
||||
id="path385"
|
||||
sodipodi:type="arc"
|
||||
sodipodi:cx="105.9076"
|
||||
sodipodi:cy="136.60933"
|
||||
sodipodi:rx="128.47229"
|
||||
sodipodi:ry="128.47374"
|
||||
sodipodi:start="4.6934522"
|
||||
sodipodi:end="4.6302294"
|
||||
sodipodi:open="true"
|
||||
sodipodi:arc-type="arc"
|
||||
d="M 103.47489,8.1586244 A 128.47229,128.47374 0 0 1 234.30236,132.14673 128.47229,128.47374 0 0 1 112.39888,264.91897 128.47229,128.47374 0 0 1 -22.281972,145.12781 128.47229,128.47374 0 0 1 95.364242,8.5689572" /><path
|
||||
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.029;stroke-miterlimit:22.2;stroke-dasharray:none"
|
||||
d="m 117.05457,164.17654 c 8.03452,-0.0462 8.49627,-1.08512 8.49627,-1.08512 9.82856,-19.81483 22.21219,-26.80177 41.97341,-36.29385 8.34356,-3.68297 19.0164,-10.96258 27.61288,-17.13106 6.26354,-4.49445 12.05177,-10.297107 20.17864,-13.621734 8.53145,-3.490137 -10.80504,12.328824 -10.80504,12.328824 -9.0042,8.12686 -14.95674,11.81852 -22.02565,15.97668 -10.7129,6.30168 -14.31359,8.34425 -19.30131,12.14412 -5.80492,4.42245 -9.88634,7.92764 -13.66791,10.29711 -7.29571,4.57136 -9.28125,7.66511 -10.11241,8.77332 -0.83116,1.10821 -3.97108,5.95663 -3.97108,5.95663 1.66231,-0.50793 4.94076,-1.01586 4.94076,-1.01586 0.0462,2.40112 -5.49486,6.7416 -5.49486,6.7416 -2.77053,1.89319 -5.77193,4.47901 -5.77193,4.47901 1.20056,11.49767 -5.58722,18.0084 -5.58722,18.0084 -9.28124,10.20475 -18.3427,0.76386 -20.22481,-4.20196 -3.17843,-8.38609 -13.802587,-15.78912 -23.687967,-17.82369 -11.028807,-2.26991 -14.129558,-1.84483 -21.05597,-3.46315 -19.763059,-4.61754 -25.561421,-6.75206 -38.510261,-9.37361 -7.526586,-1.52379 -13.8064366,-4.20196 -13.8064366,-4.20196 0,0 -10.8973881,-4.84841 -13.5293844,-8.35774 -2.6319962,-3.50933 7.20335825,-0.3694 7.20335825,-0.3694 0,0 5.60046125,2.09797 8.49626865,2.81669 19.1627801,4.75607 27.5461821,6.95957 33.8465491,7.24953 6.300367,0.29001 17.394868,0.27841 17.394868,0.27841 17.211282,0.25025 32.426083,4.51346 41.247855,12.11969 1.92916,1.66334 3.41698,1.73157 4.42129,1.92782 3.28987,0.10945 8.16149,-1.974 11.74009,-2.1587 z"
|
||||
id="path455"
|
||||
sodipodi:nodetypes="cczccscccsscccccsssscssccscc" /></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.5 KiB |
|
|
@ -1,70 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="508mm"
|
||||
height="341.04791mm"
|
||||
viewBox="0 0 508 341.04791"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||
sodipodi:docname="viossaFlag.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview7"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.7299663"
|
||||
inkscape:cx="86.126161"
|
||||
inkscape:cy="613.87795"
|
||||
inkscape:window-width="2400"
|
||||
inkscape:window-height="1411"
|
||||
inkscape:window-x="2391"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="layer2"><inkscape:grid
|
||||
type="xygrid"
|
||||
id="grid302" /></sodipodi:namedview><defs
|
||||
id="defs2" /><g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer2"
|
||||
inkscape:label="Layer 2"
|
||||
transform="translate(150.306,34.583659)"><g
|
||||
id="g5950"
|
||||
inkscape:export-filename="..\Finished\ViossaFlagFullSize.svg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><rect
|
||||
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.0615961;stroke-miterlimit:22.2;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="rect5873"
|
||||
width="508"
|
||||
height="341.04791"
|
||||
x="-150.306"
|
||||
y="-34.58366" /><g
|
||||
id="g5819"
|
||||
transform="translate(-2.2136002,-0.66903305)"><path
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.399354;stroke-miterlimit:22.2"
|
||||
id="path385"
|
||||
sodipodi:type="arc"
|
||||
sodipodi:cx="105.9076"
|
||||
sodipodi:cy="136.60933"
|
||||
sodipodi:rx="128.47229"
|
||||
sodipodi:ry="128.47374"
|
||||
sodipodi:start="4.6934522"
|
||||
sodipodi:end="4.6302294"
|
||||
sodipodi:open="true"
|
||||
sodipodi:arc-type="arc"
|
||||
d="M 103.47489,8.1586244 A 128.47229,128.47374 0 0 1 234.30236,132.14673 128.47229,128.47374 0 0 1 112.39888,264.91897 128.47229,128.47374 0 0 1 -22.281972,145.12781 128.47229,128.47374 0 0 1 95.364242,8.5689572" /><path
|
||||
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.029;stroke-miterlimit:22.2;stroke-dasharray:none"
|
||||
d="m 117.05457,164.17654 c 8.03452,-0.0462 8.49627,-1.08512 8.49627,-1.08512 9.82856,-19.81483 22.21219,-26.80177 41.97341,-36.29385 8.34356,-3.68297 19.0164,-10.96258 27.61288,-17.13106 6.26354,-4.49445 12.05177,-10.297107 20.17864,-13.621734 8.53145,-3.490137 -10.80504,12.328824 -10.80504,12.328824 -9.0042,8.12686 -14.95674,11.81852 -22.02565,15.97668 -10.7129,6.30168 -14.31359,8.34425 -19.30131,12.14412 -5.80492,4.42245 -9.88634,7.92764 -13.66791,10.29711 -7.29571,4.57136 -9.28125,7.66511 -10.11241,8.77332 -0.83116,1.10821 -3.97108,5.95663 -3.97108,5.95663 1.66231,-0.50793 4.94076,-1.01586 4.94076,-1.01586 0.0462,2.40112 -5.49486,6.7416 -5.49486,6.7416 -2.77053,1.89319 -5.77193,4.47901 -5.77193,4.47901 1.20056,11.49767 -5.58722,18.0084 -5.58722,18.0084 -9.28124,10.20475 -18.3427,0.76386 -20.22481,-4.20196 -3.17843,-8.38609 -13.802587,-15.78912 -23.687967,-17.82369 -11.028807,-2.26991 -14.129558,-1.84483 -21.05597,-3.46315 -19.763059,-4.61754 -25.561421,-6.75206 -38.510261,-9.37361 -7.526586,-1.52379 -13.8064366,-4.20196 -13.8064366,-4.20196 0,0 -10.8973881,-4.84841 -13.5293844,-8.35774 -2.6319962,-3.50933 7.20335825,-0.3694 7.20335825,-0.3694 0,0 5.60046125,2.09797 8.49626865,2.81669 19.1627801,4.75607 27.5461821,6.95957 33.8465491,7.24953 6.300367,0.29001 17.394868,0.27841 17.394868,0.27841 17.211282,0.25025 32.426083,4.51346 41.247855,12.11969 1.92916,1.66334 3.41698,1.73157 4.42129,1.92782 3.28987,0.10945 8.16149,-1.974 11.74009,-2.1587 z"
|
||||
id="path455"
|
||||
sodipodi:nodetypes="cczccscccsscccccsssscssccscc" /></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
21564
apps/vdn-static/src/assets/bulma.css
vendored
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
|
@ -1,77 +0,0 @@
|
|||
localeName = "English"
|
||||
vilanticLangs-viossa = "Viossa"
|
||||
vilanticLangs-wodox = "Wodoch"
|
||||
navbar-whatIsViossa = "What is Viossa?"
|
||||
navbar-resources = "Resources"
|
||||
navbar-kotoba = "Kotoba"
|
||||
home-sections-whatIsViossa-title = "What is Viossa?"
|
||||
home-sections-whatIsViossa-body = "Viossa is a community-created artificial pidgin language, created to simulate the formation of natural pidgin languages. Viossa is characterized by its lack of standardization, with each speaker developing a personal idiolect. Spelling and pronunciation can vary greatly, and serve as a form of personal self-expression. Viossa is learnt and taught entirely by immersion — translation is prohibited while learning."
|
||||
home-sections-historyOfViossa-title = "History of Viossa"
|
||||
home-sections-historyOfViossa-body = "Viossa began as a Skype group in 2014, created by members of the r/conlangs community on Reddit, as an experiment to simulate the formation of a pidgin language. Pidgins are simplified languages resulting from contact between populations with no shared common language. Unlike most pidgins, which usually have two to three contributor languages, Viossa comes from many diverse languages. This is because people from all around the world helped to contribute to Viossa's vocabulary."
|
||||
home-sections-community-title = "Community"
|
||||
home-sections-community-body = "The Viossa community is rich and colourful, drawing from many global traditions due to its worldwide online membership. Since the teaching culture puts an emphasis on linguistic immersion, and discourages prescriptivism, the culture of Viossa is as diverse and varied as the language and the people who speak it. For many, their personal dialect is a key form of identity and expression. The fluid nature of Viossa and lack of defined meanings makes Viossa popular for creative purposes, such as poetry and songwriting."
|
||||
home-images-viossaFlag-alt = "Flag of the Viossa Language"
|
||||
resources-title = "Learning Resources"
|
||||
resources-resources-discord-title = "Discord Server"
|
||||
resources-resources-discord-subtitle = "This is where most of the action happens! Hop on in!"
|
||||
resources-resources-discord-desc = "Originally started in 2015 something something read the rules here, then click the link below to join!"
|
||||
resources-resources-discord-buttons-join-label = "Join"
|
||||
resources-resources-discord-buttons-rules-label = "Rules"
|
||||
resources-images-discordLogo-alt = "Discord logo"
|
||||
kotoba-title = "Tropos-agnostic search"
|
||||
kotoba-searchHelp = "To searcn tropos-agnostically, enter a term below."
|
||||
discord-rulesPage-title = "Discord Server Rules"
|
||||
discord-rulesPage-overview-title = "Overview"
|
||||
discord-rulesPage-overview-help = "Click any rule to see details."
|
||||
discord-rulesPage-rules-noTranslation-overview-text = md "No translation! Do not translate to/from Viossa on the server, except the big four translatables (you can learn in hard mode without them!)"
|
||||
discord-rulesPage-rules-noTranslation-overview-subtext = md --
|
||||
discord-rulesPage-rules-noTranslation-section-header = "Rule { $ruleNumber }: No translation"
|
||||
discord-rulesPage-rules-noTranslation-section-body = md
|
||||
"Translation is not how we learn and teach Viossa. Instead, we teach using pictures, diagrams, video calls, and other aids to couple words to meaning."
|
||||
"On the Viossa Diskordserver, you are allowed to translate the following four words. If you want an extra challenge, don't unspoiler the text:"
|
||||
"*TODO - big 4*"
|
||||
"Outside of the teaching-learning cycle, we also make an exception for artistic translations (such as those of songs, books, or poems), as well as for academic translations (such as for a formal research paper). In both cases, this exception is dependent on translations of either class appearing in the appropriate place. If you're not sure where that is, please ask."
|
||||
"Additionally, please don't attempt to derive or share translation-based learning materials on-server, or poach members for such a purpose."
|
||||
discord-rulesPage-rules-lfsv-overview-text = md "If it's understood, it's Viossa."
|
||||
discord-rulesPage-rules-lfsv-overview-subtext = md --
|
||||
discord-rulesPage-rules-lfsv-section-header = "Rule { $ruleNumber }: If it's understood, it's Viossa"
|
||||
discord-rulesPage-rules-lfsv-section-body = md
|
||||
"All that is required to speak Viossa is that other speakers be able to understand you. There is no right or wrong way to speak or write, and no global standard."
|
||||
"However, Viossa is a collaborative group project: members should strive to make others understand them, and in return make an effort to understand others."
|
||||
discord-rulesPage-rules-viossaOnlyChats-overview-text = md "The chats in the Viossa Only category are Viossa only."
|
||||
discord-rulesPage-rules-viossaOnlyChats-overview-subtext = md --
|
||||
discord-rulesPage-rules-viossaOnlyChats-section-header = "Rule { $ruleNumber }: Viossa-only chats"
|
||||
discord-rulesPage-rules-viossaOnlyChats-section-body = md
|
||||
"Chats in the Viossa Only section do not permit English. If you must use English to coach learners on the learning process, go to **#meta** instead."
|
||||
"This doesn't mean that other channels are English-only, though! Viossa is allowed everywhere."
|
||||
discord-rulesPage-rules-sfw-overview-text = md "This server is SFW. No sexually explicit, gory, or violent content."
|
||||
discord-rulesPage-rules-sfw-overview-subtext = md --
|
||||
discord-rulesPage-rules-sfw-section-header = "Rule { $ruleNumber }: SFW"
|
||||
discord-rulesPage-rules-sfw-section-body = md
|
||||
"If a mod does not like what you have posted, they will inform you; see [Rule 6](internal.replace:#rule-6). This is a public Discord server; think before you post."
|
||||
discord-rulesPage-rules-respectOthers-overview-text = md "Don't use hate speech, and respect each other."
|
||||
discord-rulesPage-rules-respectOthers-overview-subtext = md --
|
||||
discord-rulesPage-rules-respectOthers-section-header = "Rule { $ruleNumber }: Respect one another"
|
||||
discord-rulesPage-rules-respectOthers-section-body = md
|
||||
"Respect one another. Using slurs or hate speech against others, whether on- or off-server, or advocating for violence are not welcome. This is an LGBTQ+ friendly international community."
|
||||
discord-rulesPage-rules-respectStaff-overview-text = md "Respect the rulings of the staff (**@Yewald** and **@Yewaldnen**)."
|
||||
discord-rulesPage-rules-respectStaff-overview-subtext = md --
|
||||
discord-rulesPage-rules-respectStaff-section-header = "Rule { $ruleNumber }: Respect the staff's rulings"
|
||||
discord-rulesPage-rules-respectStaff-section-body = md
|
||||
"The word of staff (the Yewald as well as the Yewaldnen) is final, and they may kick, ban, or mute members or change members' access permissions to make sure this environment stays respectful and puts the Viossa community first."
|
||||
"Appeals will always be considered, and if you feel that a mod action was inappropriate, you can DM any Yewald or open a ticket with YAGPDB's /tickets open command."
|
||||
"If you are banned, there will be instructions on how to appeal the ban, however, please take the time to reflect on the ban reason before appealing."
|
||||
discord-rulesPage-rules-controversialTopics-overview-text = md "Discussion of controversial topics (politics, war, etc.) should be directed to **#polite**, which requires the **@Ike** role to view, which is itself locked behind **@Viossadjin** and **@mellandjin**."
|
||||
discord-rulesPage-rules-controversialTopics-overview-subtext = md "**#feels-and-advice** is for talking about your feelings openly, but we draw the line at suicidal or violent ideation. These are trains of thought to be brought to a therapist, and are not jokes. Because of their seriousness, they simply don't belong here."
|
||||
discord-rulesPage-rules-controversialTopics-section-header = "Rule { $ruleNumber }: #polite and ike"
|
||||
discord-rulesPage-rules-controversialTopics-section-body = md
|
||||
"Many are life's troubling realities, and vast is our need to discuss them. The ike category is an opt-in set of chats where discussion of heavy, sensitive, or potentially contentious topics is allowed, provided that users are especially respectful of each other during such discussions. By accepting the ike role, you agree to adhere to this rule and encourage others to do the same."
|
||||
"# Venting vs seeking advice"
|
||||
"Sometimes you want to let people know that you're dealing with an issue and just be acknowledged, other times you want help in solving a problem. If you are open to one but not the other, it's often a good idea to let people know as part of the discussion so that you can receive the kind of responses you are looking for."
|
||||
"# Self-harm and Violence"
|
||||
"While discussing self-harm in general is allowed (with appropriate and clear use of content warnings), this server in itself is not an emergency mental health resource, and is not a substitute for professional help. Asking others for advice in finding support or resources off-server is fine, but asking others to participate in talking you down is inappropriate."
|
||||
"You should not use this space to:"
|
||||
"- express intent or desire to harm yourself or others"
|
||||
"- solicit help in stopping yourself from harming yourself or someone else"
|
||||
"By crossing these boundaries, please be aware that you are asking members of the server (including moderators and the owner) to perform a role for which they are not trained or equipped. At the moderators' sole discretion, this may not be tolerated and may result in a warning, timeout, removal of the **@ike** role, or removal from the server."
|
||||
"If you are struggling with thoughts of this nature, but are not immediately in danger, please consider seeking counseling. If you are experiencing an immediate crisis, please call **988** (in the United States), **999** (in the UK), or locate an emergency hotline appropriate for you. A list of resources by country exists here: [](external.new:https://blog.opencounseling.com/suicide-hotlines/)"
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
localeName = "Viossa"
|
||||
home-sections-whatIsViossa-title = "Kafaen afto Viossa"
|
||||
home-sections-whatIsViossa-body = "Viossa tte glossa mahena grun vi nai vil fshtojena na bakadjin, grun vi svinnur ja! De aldjin zovti lera ne"
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
localeName = "wodox"
|
||||
vilanticLangs-viossa = "viosox"
|
||||
vilanticLangs-wodox = "wodox"
|
||||
navbar-whatIsViossa = "viosox e ano?"
|
||||
navbar-resources = "tropos"
|
||||
navbar-kotoba = "mot o viosox"
|
||||
home-sections-whatIsViossa-title = "viosox e ano?"
|
||||
home-sections-whatIsViossa-body = "viosox e hez ox pamzal, zoz stende zalkun tuo mit multa nengwi ox. zal o viosox stende lik zal o hez il ox keta, zalilkun wi tuo mit multa nengwi ox. mono i fal o viosox stendenai; omni axsi o viosox zal nengokun fal o viosox, de falmot wi falax o il stende e keko trenengwi tua o nengwi stende, ge fala e keko lik ro o tuo viosoxsi. genil viosox ibe il wi nengwi stende axkun ge pisakun po tuo ox — stende gen muskunnai mit zaiox."
|
||||
home-sections-historyOfViossa-title = "zal o viosox"
|
||||
home-sections-historyOfViossa-body = "wi o zal o viosox stende po multa o Skype wi 2014 ibe stendera o multa r/conlangs o Reddit. zalsi o viosox danzalgo hez, tuo zal o viosox e lik zal o hez il ox keta, zalil tuo ox mit nengwi multa ox ibe zalsi fiemnaikun sama i ox. viosox e nengwi tuo ox pamzal keta; zalilkun keko ox keta mit lik du wi tre ox, aga zalil viosox mit multa wi plus obo o ox na il ox keta ibe zalsi o viosox stende po multa mi o mo."
|
||||
home-sections-community-title = "viosoxsi"
|
||||
home-sections-community-body = "nengwi multa ro o viosoxsi stende ibe stendenura po nengwi multa mi o mo ge wekakunnura zai nengwi viosoxsi po jilobo. ibe mono i fal o viosox stendenai ge ibe viosoxsi zalkun nengwi multa fal o viosox, de nengwi zoz ko o ro lik ro o viosoxsi stende po ro o viosa. po multa hez viosoxsi, falmot wi falax o tuo stende stende po ro o tuo stende. ibe mono i fal o viosox stendenai ge ibe ro o mot inkun nengwi po nengwi viosoxsi, de multa stende amanata hez, zal zalgonukun surat au mola au sucik."
|
||||
home-images-viossaFlag-alt = "fomma o viosox"
|
||||
resources-title = "tropos o gen"
|
||||
resources-resources-discord-title = "server o Diskord"
|
||||
resources-resources-discord-subtitle = "axilkun ge genilkun po ce! wekatutsa!"
|
||||
resources-resources-discord-desc = "danzalil hez server po 2015. ibe dutukun musra po ce, de ibe wiftutsakun dof po pam, de wekatukun po server!"
|
||||
resources-resources-discord-buttons-join-label = "wekatutsa"
|
||||
resources-resources-discord-buttons-rules-label = "musra"
|
||||
resources-images-discordLogo-alt = "surat o Diskord"
|
||||
kotoba-title = "zalkuketutsa mot o viosox mit il o omni falmot"
|
||||
kotoba-searchHelp = "ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun."
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
@use "./bulma.css";
|
||||
|
||||
* {
|
||||
--bulma-primary-h: 196deg;
|
||||
--bulma-primary-l: 50%;
|
||||
--bulma-primary-s: 100%;
|
||||
|
||||
--bulma-link-h: 293deg;
|
||||
--bulma-link-l: 50%;
|
||||
--bulma-link-s: 45%;
|
||||
|
||||
--bulma-warning-h: 31deg;
|
||||
--bulma-warning-l: 75%;
|
||||
--bulma-warning-s: 100%;
|
||||
|
||||
--bulma-info-h: 90deg;
|
||||
--bulma-info-l: 50%;
|
||||
--bulma-info-s: 45%;
|
||||
|
||||
--bulma-family-primary: Nunito, Inter, SF Pro, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
--bulma-family-secondary: Nunito, Inter, SF Pro, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
--bulma-body-family: Nunito, Inter, SF Pro, Segoe UI, Roboto, Oxygen, Ubuntu, Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
// Correct for fixed-top header when jumping to element by id in link (anchor)
|
||||
html {
|
||||
scroll-padding-top: var(--bulma-navbar-height);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
|
|
@ -1,14 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { AnchorHTMLAttributes } from "vue";
|
||||
|
||||
defineProps<{
|
||||
onClick: AnchorHTMLAttributes["onClick"];
|
||||
}>() satisfies AnchorHTMLAttributes;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - we're wrapping it into a useable type -->
|
||||
<a v-bind="$props">
|
||||
<slot />
|
||||
</a>
|
||||
</template>
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
<script setup lang="ts" generic="Slot extends string">
|
||||
import {
|
||||
getCurrentInstance,
|
||||
onMounted,
|
||||
type DeepReadonly,
|
||||
type VNode,
|
||||
} from "vue";
|
||||
import MarkdownParts from "./MarkdownParts.vue";
|
||||
import OptionalParent from "./OptionalParent.vue";
|
||||
import type { Markdown } from "@/new-i18n-lib/markdown";
|
||||
import type { CssClass } from "@/utils/css";
|
||||
|
||||
const props = defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments -- I don't know why this error is here
|
||||
markdown: DeepReadonly<Markdown<Slot>>;
|
||||
lineClass?: CssClass;
|
||||
tag?: string;
|
||||
}>();
|
||||
const providedSlots =
|
||||
defineSlots<{ [K in DeepReadonly<Slot>]: () => VNode[] }>();
|
||||
console.log(Object.entries(props.markdown));
|
||||
|
||||
function tryResolveComponentName(type: unknown): string | undefined {
|
||||
if (!type || typeof type !== "object") return undefined;
|
||||
const maybeType = type as { name?: string; __file?: string };
|
||||
if (maybeType.name) return maybeType.name;
|
||||
if (maybeType.__file) {
|
||||
const parts = maybeType.__file.split(/[\\/]/);
|
||||
const filename = parts.at(-1);
|
||||
if (filename === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filenameParts = filename.split(".");
|
||||
filenameParts.pop();
|
||||
return filenameParts.join(".");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveComponentName(type: unknown): string {
|
||||
return tryResolveComponentName(type) ?? "(unresolvable)";
|
||||
}
|
||||
|
||||
const getComponentStack = () => {
|
||||
const instance = getCurrentInstance();
|
||||
if (!instance) return "";
|
||||
const names: string[] = [resolveComponentName(instance.type)];
|
||||
let current = instance.parent;
|
||||
while (current) {
|
||||
names.push(resolveComponentName(current.type));
|
||||
current = current.parent;
|
||||
}
|
||||
return names.length > 0 ? `\n\tComponent Stack: ${names.join(" > ")}` : "";
|
||||
};
|
||||
|
||||
// Validate required slots at runtime
|
||||
onMounted(() => {
|
||||
const requiredSlots = props.markdown.slots;
|
||||
const missingSlots = requiredSlots.filter(
|
||||
(slot) => providedSlots[slot] === undefined,
|
||||
);
|
||||
|
||||
if (missingSlots.length > 0) {
|
||||
const componentStack = getComponentStack();
|
||||
throw new Error(
|
||||
`Markdown component is missing slots!\n\tMissing Slots: ${missingSlots.join(", ")}${componentStack}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OptionalParent :is="tag">
|
||||
<template v-for="(line, index) in markdown.elements" :key="index">
|
||||
<p v-if="line.type === 'paragraph'" :class="lineClass">
|
||||
<MarkdownParts
|
||||
:elements="line.paragraph.spans"
|
||||
:slots="markdown.slots">
|
||||
<template
|
||||
v-for="(slot, name) in providedSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</p>
|
||||
<h3 v-else-if="line.type === 'header'" :class="lineClass">
|
||||
<MarkdownParts
|
||||
:elements="line.header.spans"
|
||||
:slots="markdown.slots">
|
||||
<template
|
||||
v-for="(slot, name) in providedSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</h3>
|
||||
<ul v-else-if="line.type === 'ulist'" :class="lineClass">
|
||||
<li v-for="item in line.ulist.items">
|
||||
<MarkdownParts :elements="item" :slots="markdown.slots">
|
||||
<template
|
||||
v-for="(slot, name) in providedSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</OptionalParent>
|
||||
</template>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<script setup lang="ts" generic="Slot extends string">
|
||||
import { type DeepReadonly, type VNode } from "vue";
|
||||
import SmartLink from "../atoms/SmartLink.vue";
|
||||
import type { MarkdownSpan } from "@/new-i18n-lib/markdown";
|
||||
|
||||
defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
|
||||
elements: DeepReadonly<MarkdownSpan<Slot>[]>;
|
||||
slots: DeepReadonly<Slot[]>;
|
||||
}>();
|
||||
|
||||
const vueSlots = defineSlots<{ [K in Slot]: () => VNode[] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(part, index) in elements" :key="index">
|
||||
<template v-if="part.type === 'plain'">
|
||||
{{ part.plain }}
|
||||
</template>
|
||||
<template v-else-if="part.type === 'slot'">
|
||||
<template v-for="(slot, name) in vueSlots" :key="name">
|
||||
<template v-if="name === part.slot">
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="part.type === 'bold'">
|
||||
<b>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
|
||||
<MarkdownParts :elements="part.bold" :slots="slots">
|
||||
<template
|
||||
v-for="(slot, name) in vueSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</b>
|
||||
</template>
|
||||
<template v-else-if="part.type === 'italic'">
|
||||
<i>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
|
||||
<MarkdownParts :elements="part.italic" :slots="slots">
|
||||
<template
|
||||
v-for="(slot, name) in vueSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</i>
|
||||
</template>
|
||||
<template v-else-if="part.type === 'link'">
|
||||
<SmartLink :to="part.link.to" :new-tab="part.link.newTab">
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - it can use itself -->
|
||||
<MarkdownParts :elements="part.link.label" :slots="slots">
|
||||
<template
|
||||
v-for="(slot, name) in vueSlots"
|
||||
:key="name"
|
||||
#[name]>
|
||||
<component :is="slot" />
|
||||
</template>
|
||||
</MarkdownParts>
|
||||
</SmartLink>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{ is?: string | object | null | undefined }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component v-if="is !== null && is !== undefined" :is="is">
|
||||
<slot />
|
||||
</component>
|
||||
<template v-else>
|
||||
<slot />
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { RichText, RichTextPart } from "../../i18n/rich"; // relative import for vue sfc compiler
|
||||
|
||||
const props = defineProps<{ content: RichText | RichTextPart[] }>();
|
||||
|
||||
const parts = computed<RichTextPart[]>(() => {
|
||||
const content = props.content;
|
||||
if (Array.isArray(content)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content.parts;
|
||||
});
|
||||
|
||||
console.log(parts.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(part, index) in parts" :key="index">
|
||||
<template v-if="typeof part === 'string'">
|
||||
{{ part }}
|
||||
</template>
|
||||
<template v-else-if="part.type === 'bold'">
|
||||
<b><RichText :content="part.bold" /></b>
|
||||
</template>
|
||||
<template v-else-if="part.type === 'italic'">
|
||||
<i><RichText :content="part.italic" /></i>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import type { SmartDest } from "@/utils/smart-dest";
|
||||
|
||||
export interface SmartLinkProps {
|
||||
to: SmartDest;
|
||||
newTab?: boolean;
|
||||
covert?: boolean;
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { CssClass } from "@/utils/css";
|
||||
import { computed, ref } from "vue";
|
||||
import type { SmartLinkProps } from "./SmartLink";
|
||||
|
||||
const props = defineProps<SmartLinkProps>();
|
||||
|
||||
type To = { type: "a"; a: string } | { type: "routerLink"; routerLink: string };
|
||||
|
||||
const to = ((): To => {
|
||||
const { to, newTab } = props;
|
||||
switch (to.type) {
|
||||
case "external": {
|
||||
return { type: "a", a: to.external };
|
||||
}
|
||||
case "internal": {
|
||||
const { route, id } = to.internal;
|
||||
const endpoint = `${route ?? ""}${id === undefined ? "" : `#${id}`}`;
|
||||
|
||||
if (newTab || id !== undefined) {
|
||||
return { type: "a", a: endpoint };
|
||||
}
|
||||
|
||||
// <RouterLink> can only be used for route-only endpoints
|
||||
// that require no other special functionality of <a>
|
||||
return { type: "routerLink", routerLink: endpoint };
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const isHovered = ref(false);
|
||||
|
||||
const classes = computed<CssClass>(() => [
|
||||
props.covert && !isHovered.value && "has-text-text",
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - we need to at least use <a> once to create this wrapper type -->
|
||||
<a
|
||||
v-if="to.type === 'a'"
|
||||
:href="to.a"
|
||||
:target="newTab ? '_blank' : undefined"
|
||||
rel="noopener noreferrer nofollow"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
:class="classes">
|
||||
<slot />
|
||||
</a>
|
||||
<!-- eslint-disable-next-line vue/no-restricted-html-elements - we need to at least use <RouterLink> once to create this wrapper type -->
|
||||
<RouterLink v-else :to="to.routerLink" :class="classes">
|
||||
<span @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
||||
<slot />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# What are atoms?
|
||||
|
||||
https://bradfrost.com/blog/post/atomic-web-design/
|
||||
|
||||
Atoms are the basic building blocks of matter. Applied to web interfaces, atoms are our HTML tags, such as a form label, an input or a button.
|
||||
|
||||
Atoms can also include more abstract elements like color palettes, fonts and even more invisible aspects of an interface like animations.
|
||||
|
||||
Like atoms in nature they’re fairly abstract and often not terribly useful on their own. However, they’re good as a reference in the context of a pattern library as you can see all your global styles laid out at a glance.
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import SmartLink from "../atoms/SmartLink.vue";
|
||||
import type { Value } from "@/utils/types";
|
||||
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";
|
||||
import type { Locale } from "@/new-i18n";
|
||||
import type { DeepReadonly } from "vue";
|
||||
import { isEmptyMarkdown } from "@/new-i18n-lib/markdown";
|
||||
|
||||
const props = defineProps<{
|
||||
overview: DeepReadonly<
|
||||
Value<Locale["discord"]["rulesPage"]["rules"]>["overview"]
|
||||
>;
|
||||
ruleNumber: number;
|
||||
}>();
|
||||
|
||||
const subtext = props.overview.subtext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<SmartLink
|
||||
covert
|
||||
:to="{ type: 'internal', internal: { id: `rule-${ruleNumber}` } }"
|
||||
:style="{ width: 'fit-content', display: 'inline-block' }">
|
||||
<li :style="{ width: 'fit-content' }">
|
||||
<MarkdownDisplay
|
||||
:markdown="overview.text()"
|
||||
line-class="mb-0" />
|
||||
<ul v-if="!isEmptyMarkdown(subtext)" class="mt-0 w-fit">
|
||||
<MarkdownDisplay
|
||||
tag="li"
|
||||
:style="{ width: 'fit-content' }"
|
||||
:markdown="subtext" />
|
||||
</ul>
|
||||
</li>
|
||||
</SmartLink>
|
||||
</span>
|
||||
</template>
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { Locale } from "@/new-i18n";
|
||||
import type { Value } from "@/utils/types";
|
||||
import type { DeepReadonly } from "vue";
|
||||
import MarkdownDisplay from "../atoms/MarkdownDisplay.vue";
|
||||
|
||||
defineProps<{
|
||||
section: DeepReadonly<
|
||||
Value<Locale["discord"]["rulesPage"]["rules"]>["section"]
|
||||
>;
|
||||
ruleNumber: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section content" :id="`rule-${ruleNumber}`">
|
||||
<h2>{{ section.header({ ruleNumber }) }}</h2>
|
||||
<MarkdownDisplay :markdown="section.body()" />
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
text: string;
|
||||
image?: string;
|
||||
alt?: string;
|
||||
reverse: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box my-5 px-4 py-3 columns is-vcentered">
|
||||
<template v-if="reverse">
|
||||
<div class="column">
|
||||
<h2 class="title is-4">{{ title }}</h2>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
<div class="column is-one-quarter" v-if="image">
|
||||
<figure class="image">
|
||||
<img :src="image" :alt="alt" :title="alt" />
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="column is-one-quarter" v-if="image">
|
||||
<figure class="image">
|
||||
<img :src="image" :alt="alt" :title="alt" />
|
||||
</figure>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h2 class="title is-4">{{ title }}</h2>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import type { SmartLinkProps } from "../atoms/SmartLink";
|
||||
import type { CssClass } from "@/utils/css";
|
||||
import SmartLink from "../atoms/SmartLink.vue";
|
||||
|
||||
export interface ResourceButton {
|
||||
label: string;
|
||||
link: SmartLinkProps;
|
||||
style: ResourceButtonStyle;
|
||||
}
|
||||
|
||||
export interface ResourceButtonStyle {
|
||||
color: "primary" | "warning";
|
||||
outlined?: boolean;
|
||||
}
|
||||
|
||||
function buttonStyleToClasses(style: ResourceButtonStyle): CssClass[] {
|
||||
const colorClass = (() => {
|
||||
switch (style.color) {
|
||||
case "primary": {
|
||||
return "is-primary";
|
||||
}
|
||||
case "warning": {
|
||||
return "is-warning";
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return [colorClass, style.outlined && "is-outlined"];
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
desc: string;
|
||||
image?: { src: string; alt: string };
|
||||
buttons: ResourceButton[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="box columns is-vcentered is-gap-4">
|
||||
<div class="column is-one-quarter" v-if="image">
|
||||
<figure class="image">
|
||||
<img :src="image.src" :alt="image.alt" :title="image.alt" />
|
||||
</figure>
|
||||
</div>
|
||||
<div class="column">
|
||||
<h4 class="title">{{ title }}</h4>
|
||||
<p class="subtitle">{{ subtitle }}</p>
|
||||
<p class="content">{{ desc }}</p>
|
||||
|
||||
<div class="level">
|
||||
<SmartLink
|
||||
v-for="(button, index) in buttons"
|
||||
:key="index"
|
||||
v-bind="button.link"
|
||||
:class="[
|
||||
'button',
|
||||
'is-medium',
|
||||
buttonStyleToClasses(button.style),
|
||||
]"
|
||||
>{{ button.label }}</SmartLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<template>
|
||||
<div class="box my-6">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: "PaddingWrapper" };
|
||||
</script>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# What are molecules?
|
||||
|
||||
https://bradfrost.com/blog/post/atomic-web-design/
|
||||
|
||||
Things start getting more interesting and tangible when we start combining atoms together. Molecules are groups of atoms bonded together and are the smallest fundamental units of a compound. These molecules take on their own properties and serve as the backbone of our design systems.
|
||||
|
||||
For example, a form label, input or button aren’t too useful by themselves, but combine them together as a form and now they can actually do something together.
|
||||
|
||||
Building up to molecules from atoms encourages a “do one thing and do it well” mentality. While molecules can be complex, as a rule of thumb they are relatively simple combinations of atoms built for reuse.
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/new-i18n";
|
||||
import { ref } from "vue";
|
||||
import { vOnClickOutside } from "@vueuse/components";
|
||||
import DropdownItem from "../atoms/DropdownItem.vue";
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
const isOpen = ref<boolean>(false);
|
||||
|
||||
const toggleOpen = (): void => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const close = (): void => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
const setLocaleId = (id: LocaleId): void => {
|
||||
localeId.value = id;
|
||||
close();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="['dropdown', isOpen && 'is-active']"
|
||||
v-on-click-outside="close">
|
||||
<div class="dropdown-trigger">
|
||||
<button
|
||||
class="button"
|
||||
aria-haspopup="true"
|
||||
aria-controls="dropdown-menu"
|
||||
@click="toggleOpen()">
|
||||
<span>{{ locale.localeName() }}</span>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-angle-down" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<DropdownItem
|
||||
v-for="(id, index) in LOCALE_IDS"
|
||||
:key="index"
|
||||
:class="[
|
||||
'dropdown-item is-clickable',
|
||||
localeId === id && 'is-active',
|
||||
]"
|
||||
@click="setLocaleId(id)">
|
||||
{{ useLocale({ locale: id }).value.localeName() }}
|
||||
</DropdownItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<script setup></script>
|
||||
|
||||
<template></template>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# What are organisms?
|
||||
|
||||
https://bradfrost.com/blog/post/atomic-web-design/
|
||||
|
||||
Molecules give us some building blocks to work with, and we can now combine them together to form organisms. Organisms are groups of molecules joined together to form a relatively complex, distinct section of an interface.
|
||||
|
||||
We’re starting to get increasingly concrete. A client might not be terribly interested in the molecules of a design system, but with organisms we can see the final interface beginning to take shape. Dan Mall (who I’m working with on several projects) uses element collages, which articulate ideas for a few key organisms to facilitate client conversations and shape the visual direction (all without having to construct full comps).
|
||||
|
||||
Organisms can consist of similar and/or different molecule types. For example, a masthead organism might consist of diverse components like a logo, primary navigation, search form, and list of social media channels. But a “product grid” organism might consist of the same molecule (possibly containing a product image, product title and price) repeated over and over again.
|
||||
|
||||
Building up from molecules to organisms encourages creating standalone, portable, reusable components.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
|
@ -1,514 +0,0 @@
|
|||
import type { Result, Value } from "@/utils/types";
|
||||
import {
|
||||
computeAllVariants,
|
||||
selectionChainToString,
|
||||
type PatternVariant,
|
||||
type UncompiledLocale,
|
||||
} from "./setup";
|
||||
import type { FluentBundle, FluentVariable, Message } from "@fluent/bundle";
|
||||
import { parseMarkdown, type Markdown } from "./markdown";
|
||||
import {
|
||||
configMarkdownSymbol,
|
||||
configMessageTypeSymbol,
|
||||
configStringSymbol,
|
||||
type ConfigMarkdown,
|
||||
type ConfigString,
|
||||
type InferLocaleFromConfig,
|
||||
type LocaleConfig,
|
||||
} from "./config";
|
||||
import type { Pattern } from "@fluent/bundle/esm/ast";
|
||||
|
||||
type GenericLocale = { [id: string]: GenericLocale | GenericMessageFn };
|
||||
|
||||
type GenericMessageFn = GenericStringMessageFn | GenericMarkdownMessageFn;
|
||||
|
||||
type GenericStringMessageFn = (
|
||||
placeableArgs?: GenericMessageFnPlaceableArgs,
|
||||
) => string;
|
||||
|
||||
type GenericMarkdownMessageFn = (
|
||||
placeableArgs?: GenericMessageFnPlaceableArgs,
|
||||
) => Markdown;
|
||||
|
||||
type GenericMessageFnPlaceableArgs = Record<string, FluentVariable>;
|
||||
|
||||
export interface CompileLocaleCtx<Config extends LocaleConfig> {
|
||||
bundle: FluentBundle;
|
||||
uncompiled: UncompiledLocale;
|
||||
config: Config;
|
||||
fallback: InferLocaleFromConfig<Config> | undefined;
|
||||
messageIdChain: readonly string[];
|
||||
}
|
||||
|
||||
export interface CompileLocaleRes<Locale> {
|
||||
locale: Locale;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function compileLocale<Config extends LocaleConfig>(
|
||||
ctx: CompileLocaleCtx<Config>,
|
||||
): CompileLocaleRes<InferLocaleFromConfig<Config>> {
|
||||
const {
|
||||
bundle,
|
||||
uncompiled,
|
||||
config,
|
||||
fallback,
|
||||
messageIdChain: localeMessageIdChain = [],
|
||||
} = ctx;
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
const uncompiledKeys = new Set(Object.keys(uncompiled ?? {}));
|
||||
const configKeys = new Set(Object.keys(config));
|
||||
const excessKeys = uncompiledKeys.difference(configKeys);
|
||||
if (excessKeys.size > 0) {
|
||||
errors.push(`Excess keys in record: ${[...excessKeys].join(", ")}`);
|
||||
}
|
||||
|
||||
const locale: GenericLocale = {};
|
||||
for (const [messageId, configValue] of Object.entries(config)) {
|
||||
const uncompiledValue = uncompiled?.[messageId];
|
||||
const fallbackValue = fallback?.[messageId];
|
||||
const valueMessageIdChain = [
|
||||
...localeMessageIdChain,
|
||||
messageId,
|
||||
] as const;
|
||||
|
||||
const compiledValue = ((): Value<GenericLocale> => {
|
||||
if (configMessageTypeSymbol in configValue) {
|
||||
const compiledMessage = (() => {
|
||||
if (uncompiledValue?.type !== "message") {
|
||||
errors.push(
|
||||
`Expected message for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uncompiledMessage = uncompiledValue.message;
|
||||
const compiledMessageRes = compileMessage({
|
||||
bundle,
|
||||
messageIdChain: valueMessageIdChain,
|
||||
configValue,
|
||||
uncompiledMessage,
|
||||
});
|
||||
|
||||
if (compiledMessageRes.type === "err") {
|
||||
errors.push(compiledMessageRes.err);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const compiledMessage = compiledMessageRes.ok;
|
||||
return compiledMessage;
|
||||
})();
|
||||
|
||||
if (compiledMessage !== undefined) {
|
||||
return compiledMessage;
|
||||
}
|
||||
|
||||
if (
|
||||
fallbackValue !== undefined
|
||||
&& typeof fallbackValue === "function"
|
||||
) {
|
||||
return fallbackValue as GenericMessageFn;
|
||||
}
|
||||
|
||||
return () => `[#${fmtMessageIdChain(valueMessageIdChain)}#]`;
|
||||
} else {
|
||||
const uncompiledSubrecord = (() => {
|
||||
if (uncompiledValue?.type !== "subrecord") {
|
||||
errors.push(
|
||||
`Expected subrecord for key \`${messageId}\`, found: ${typeof uncompiledValue}`,
|
||||
);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return uncompiledValue.subrecord;
|
||||
})();
|
||||
|
||||
return compileSublocale({
|
||||
subconfig: configValue,
|
||||
uncompiledSublocale: uncompiledSubrecord,
|
||||
fallbackSublocale:
|
||||
typeof fallbackValue === "function" ? undefined : (
|
||||
fallbackValue
|
||||
),
|
||||
errors,
|
||||
bundle,
|
||||
messageIdChain: valueMessageIdChain,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
locale[messageId] = compiledValue;
|
||||
}
|
||||
|
||||
// SAFETY: validated above that all keys exist and are the correct type
|
||||
return { locale: locale as InferLocaleFromConfig<Config>, errors };
|
||||
}
|
||||
|
||||
function fmtMessageIdChain(
|
||||
messageIdChain: readonly [...string[], string],
|
||||
): string {
|
||||
return messageIdChain.join("-");
|
||||
}
|
||||
|
||||
interface CompileMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
configValue: ConfigString<object> | ConfigMarkdown<object, object>;
|
||||
uncompiledMessage: Message;
|
||||
}
|
||||
|
||||
function compileMessage(
|
||||
ctx: CompileMessageCtx,
|
||||
): Result<GenericMessageFn, string> {
|
||||
const { bundle, messageIdChain, configValue, uncompiledMessage } = ctx;
|
||||
|
||||
const pattern = uncompiledMessage.value;
|
||||
if (pattern === null) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Pattern is null for message with ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// validate placeables
|
||||
if (typeof pattern !== "string") {
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "select": {
|
||||
const { selector } = element;
|
||||
if (selector.type !== "var") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected selector to be a var expression for ID: ${fmtMessageIdChain(messageIdChain)}; Found: ${selector.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
selector.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${selector.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
if (
|
||||
!Object.keys(configValue.placeables).includes(
|
||||
element.name,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found unexpected placeable name \`${element.name}\` for ID: ${fmtMessageIdChain(messageIdChain)}`,
|
||||
};
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "term":
|
||||
case "mesg":
|
||||
case "func":
|
||||
case "str":
|
||||
case "num": {
|
||||
break; // ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allVariantsRes = computeAllVariants(pattern);
|
||||
if (allVariantsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute variants for ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${allVariantsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allVariants = allVariantsRes.ok;
|
||||
|
||||
switch (configValue[configMessageTypeSymbol]) {
|
||||
case configStringSymbol: {
|
||||
return compileStringMessage({
|
||||
bundle,
|
||||
messageIdChain,
|
||||
allVariants,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
case configMarkdownSymbol: {
|
||||
return compileMarkdownMessage({
|
||||
bundle,
|
||||
configMarkdown: configValue,
|
||||
messageIdChain,
|
||||
allVariants,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CompileStringMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
allVariants: readonly PatternVariant[];
|
||||
pattern: Pattern;
|
||||
}
|
||||
|
||||
function compileStringMessage(
|
||||
ctx: CompileStringMessageCtx,
|
||||
): Result<GenericStringMessageFn, string> {
|
||||
const { bundle, messageIdChain, allVariants, pattern } = ctx;
|
||||
|
||||
// typecheck string
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const stringLiteralRes = parseMessageLiteral("string", variant.string);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
const stringRes = parseString(stringLiteral);
|
||||
if (stringRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid string for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${stringRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const stringLiteralRes = parseMessageLiteral(
|
||||
"string",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (stringLiteralRes.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid strings above
|
||||
throw new Error(
|
||||
`Failed to parse string literal after compilation!\n${stringLiteralRes.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stringLiteral = stringLiteralRes.ok;
|
||||
|
||||
const res = parseString(stringLiteral);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid strings above
|
||||
// TODO: no we dont, do that
|
||||
throw new Error(
|
||||
`Failed to parse string after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface CompileMarkdownMessageCtx {
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
configMarkdown: ConfigMarkdown<object, object>;
|
||||
allVariants: readonly PatternVariant[];
|
||||
pattern: Pattern;
|
||||
}
|
||||
|
||||
function compileMarkdownMessage(
|
||||
ctx: CompileMarkdownMessageCtx,
|
||||
): Result<GenericMarkdownMessageFn, string> {
|
||||
const { bundle, messageIdChain, configMarkdown, allVariants, pattern } =
|
||||
ctx;
|
||||
|
||||
// typecheck markdown
|
||||
|
||||
const markdownSlots = configMarkdown.features.slots;
|
||||
|
||||
// check if all variants are valid markdown
|
||||
for (const variant of allVariants) {
|
||||
const markdownLiteralRes = parseMessageLiteral("md", variant.string);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid literal for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownLiteralRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const markdownRes = parseMarkdown(
|
||||
markdownLiteral,
|
||||
Object.keys(markdownSlots),
|
||||
);
|
||||
|
||||
if (markdownRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Invalid markdown for variant \`${selectionChainToString(variant.selectionChain)}\` of ID \`${fmtMessageIdChain(messageIdChain)}\`:\n${markdownRes.err}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: will need to make sure markdown/slots are escapes when inserting variable values
|
||||
return {
|
||||
type: "ok",
|
||||
ok: (args: Record<string, FluentVariable> = {}) => {
|
||||
const markdownLiteralRes = parseMessageLiteral(
|
||||
"md",
|
||||
bundle.formatPattern(pattern, args),
|
||||
);
|
||||
|
||||
if (markdownLiteralRes.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
const markdownLiteral = markdownLiteralRes.ok;
|
||||
const res = parseMarkdown(
|
||||
markdownLiteral,
|
||||
Object.keys(markdownSlots),
|
||||
);
|
||||
|
||||
if (res.type === "err") {
|
||||
// This should hopefully never happen since we've already
|
||||
// verified all message variants parse as valid markdown above
|
||||
throw new Error(
|
||||
`Failed to parse markdown after compilation!\n${res.err}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.ok;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface CompileSublocaleCtx<Subconfig extends LocaleConfig> {
|
||||
subconfig: Subconfig;
|
||||
uncompiledSublocale: UncompiledLocale | undefined;
|
||||
fallbackSublocale: InferLocaleFromConfig<Subconfig> | undefined;
|
||||
errors: string[];
|
||||
bundle: FluentBundle;
|
||||
messageIdChain: readonly [...string[], string];
|
||||
}
|
||||
|
||||
function compileSublocale<Subconfig extends LocaleConfig>(
|
||||
ctx: CompileSublocaleCtx<Subconfig>,
|
||||
): InferLocaleFromConfig<Subconfig> {
|
||||
const {
|
||||
subconfig: configValue,
|
||||
uncompiledSublocale: recordValue,
|
||||
fallbackSublocale: fallbackValue,
|
||||
errors,
|
||||
bundle,
|
||||
messageIdChain,
|
||||
} = ctx;
|
||||
|
||||
const subrecord = recordValue;
|
||||
const compiledSubrecordRes = compileLocale({
|
||||
bundle,
|
||||
uncompiled: subrecord ?? {},
|
||||
config: configValue,
|
||||
fallback: fallbackValue,
|
||||
messageIdChain,
|
||||
});
|
||||
|
||||
errors.push(
|
||||
...compiledSubrecordRes.errors.map(
|
||||
(err) =>
|
||||
`Error when compiling subrecord with ID: \`${fmtMessageIdChain(messageIdChain)}\`:\n${err}`,
|
||||
),
|
||||
);
|
||||
|
||||
return compiledSubrecordRes.locale;
|
||||
}
|
||||
|
||||
function parseMessageLiteral(
|
||||
type: "string" | "md",
|
||||
message: string,
|
||||
): Result<string, string> {
|
||||
const trimmedMessage = message.trim();
|
||||
|
||||
const maybeStartIndexes: number[] = [];
|
||||
const firstQuoteIndex = trimmedMessage.indexOf('"');
|
||||
if (firstQuoteIndex !== -1) {
|
||||
maybeStartIndexes.push(firstQuoteIndex);
|
||||
}
|
||||
|
||||
const firstDashIndex = trimmedMessage.indexOf("-");
|
||||
if (firstDashIndex !== -1) {
|
||||
maybeStartIndexes.push(firstDashIndex);
|
||||
}
|
||||
|
||||
const stringStartIndex = Math.min(...maybeStartIndexes);
|
||||
|
||||
const actualPrefix = trimmedMessage.substring(0, stringStartIndex).trim();
|
||||
const expectedPrefix = (() => {
|
||||
switch (type) {
|
||||
case "string": {
|
||||
return "";
|
||||
}
|
||||
case "md": {
|
||||
return "md";
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (actualPrefix !== expectedPrefix) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected prefix "${expectedPrefix}" for message with type \`${type}\`; Found: "${actualPrefix}"`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: trimmedMessage.substring(actualPrefix.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseString(message: string): Result<string, string> {
|
||||
const AFFIX = '"';
|
||||
if (!message.startsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to start with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
if (!message.endsWith(AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `String message expected to end with \`${AFFIX}\``,
|
||||
};
|
||||
}
|
||||
|
||||
const deprefixed = message.substring(AFFIX.length);
|
||||
const dequoted = deprefixed.substring(0, deprefixed.length - AFFIX.length);
|
||||
return { type: "ok", ok: dequoted };
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { type Markdown } from "./markdown";
|
||||
|
||||
export const configMessageTypeSymbol: unique symbol =
|
||||
Symbol("configMessageType");
|
||||
|
||||
export const configStringSymbol: unique symbol = Symbol("configString");
|
||||
export interface ConfigString<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
> {
|
||||
[configMessageTypeSymbol]: typeof configStringSymbol;
|
||||
placeables: Placeables;
|
||||
}
|
||||
|
||||
export const configMarkdownSymbol: unique symbol = Symbol("configMarkdown");
|
||||
export interface ConfigMarkdown<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||
> {
|
||||
[configMessageTypeSymbol]: typeof configMarkdownSymbol;
|
||||
placeables: Placeables;
|
||||
features: ConfigMarkdownFeatures<Slots>;
|
||||
}
|
||||
|
||||
export interface ConfigPlaceableInfo<
|
||||
Type extends "string" | "number" = "string" | "number",
|
||||
> {
|
||||
type: Type;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface ConfigSlotInfo {}
|
||||
|
||||
export interface ConfigMarkdownFeatures<
|
||||
Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||
> {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
header?: boolean;
|
||||
link?: boolean;
|
||||
ulist?: boolean;
|
||||
slots: Slots;
|
||||
}
|
||||
|
||||
export function string<
|
||||
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
>(
|
||||
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
|
||||
): ConfigString<Placeables> {
|
||||
return {
|
||||
[configMessageTypeSymbol]: configStringSymbol,
|
||||
placeables: opt.placeables,
|
||||
};
|
||||
}
|
||||
|
||||
export function markdown<
|
||||
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
const Slots extends Partial<Record<string, ConfigSlotInfo>>,
|
||||
>(
|
||||
opt: Omit<
|
||||
ConfigMarkdown<Placeables, Slots>,
|
||||
typeof configMessageTypeSymbol
|
||||
>,
|
||||
): ConfigMarkdown<Placeables, Slots> {
|
||||
return {
|
||||
[configMessageTypeSymbol]: configMarkdownSymbol,
|
||||
placeables: opt.placeables,
|
||||
features: opt.features,
|
||||
};
|
||||
}
|
||||
|
||||
export type LocaleConfig = {
|
||||
[id: string]:
|
||||
| ConfigString<object>
|
||||
| ConfigMarkdown<object, object>
|
||||
| LocaleConfig;
|
||||
};
|
||||
|
||||
type MessageCtx<
|
||||
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
|
||||
> = {
|
||||
[K in keyof Placeables]: ResolvedPlaceableType<
|
||||
PlaceableType<Exclude<Placeables[K], undefined>>
|
||||
>;
|
||||
};
|
||||
|
||||
type PlaceableType<Var extends ConfigPlaceableInfo> =
|
||||
Var extends ConfigPlaceableInfo<infer Type> ? Type : never;
|
||||
|
||||
type ResolvedPlaceableType<Type extends "string" | "number"> =
|
||||
Type extends "string" ? string
|
||||
: Type extends "number" ? number
|
||||
: never;
|
||||
|
||||
export type InferLocaleFromConfig<Config extends LocaleConfig> = {
|
||||
[K in keyof Config]: Config[K] extends LocaleConfig ?
|
||||
InferLocaleFromConfig<Config[K]>
|
||||
: Config[K] extends ConfigString<infer Placeables> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => string
|
||||
: (ctx: MessageCtx<Placeables>) => string
|
||||
: Config[K] extends ConfigMarkdown<infer Placeables, object> ?
|
||||
object extends MessageCtx<Placeables> ?
|
||||
() => Markdown
|
||||
: (ctx: MessageCtx<Placeables>) => Markdown
|
||||
: never;
|
||||
};
|
||||
|
||||
export function record<const Key extends PropertyKey, T>(
|
||||
keys: readonly Key[],
|
||||
initializer: () => T,
|
||||
): Record<Key, T> {
|
||||
const obj: Partial<Record<Key, T>> = {};
|
||||
for (const key of keys) {
|
||||
obj[key] = initializer();
|
||||
}
|
||||
|
||||
// SAFETY: we set all properties from keys array above
|
||||
return obj as Record<Key, T>;
|
||||
}
|
||||
|
|
@ -1,810 +0,0 @@
|
|||
import type {
|
||||
SmartDest,
|
||||
SmartExternalDest,
|
||||
SmartInternalDest,
|
||||
} from "@/utils/smart-dest";
|
||||
import type { Result } from "@/utils/types";
|
||||
import type { RouteNamedMap } from "vue-router/auto-routes";
|
||||
import { routes } from "vue-router/auto-routes";
|
||||
|
||||
export interface Markdown<Slot extends string = string> {
|
||||
elements: MarkdownElement<Slot>[];
|
||||
slots: Slot[];
|
||||
}
|
||||
|
||||
export type MarkdownElement<Slot extends string = string> =
|
||||
| { type: "paragraph"; paragraph: { spans: MarkdownSpan<Slot>[] } }
|
||||
| { type: "header"; header: { spans: MarkdownSpan<Slot>[] } }
|
||||
| { type: "ulist"; ulist: { items: MarkdownSpan<Slot>[][] } };
|
||||
|
||||
type MarkdownLine<Slot extends string = string> = {
|
||||
type: "paragraph" | "header" | "ulistItem";
|
||||
spans: MarkdownSpan<Slot>[];
|
||||
};
|
||||
|
||||
export type MarkdownFeature = "header" | "ulist" | "italic" | "bold" | "link";
|
||||
|
||||
export type MarkdownSpan<Slot extends string = string> =
|
||||
| { type: "plain"; plain: string }
|
||||
| { type: "italic"; italic: MarkdownSpan[] }
|
||||
| { type: "bold"; bold: MarkdownSpan[] }
|
||||
| {
|
||||
type: "link";
|
||||
link: { label: MarkdownSpan[]; to: SmartDest; newTab: boolean };
|
||||
}
|
||||
| { type: "slot"; slot: Slot };
|
||||
|
||||
export function parseMarkdown<Slot extends string>(
|
||||
markdownString: string,
|
||||
slots: readonly Slot[],
|
||||
): Result<Markdown<Slot>, string> {
|
||||
const linesRes = parseMarkdownLines(markdownString, slots);
|
||||
if (linesRes.type === "err") {
|
||||
return linesRes;
|
||||
}
|
||||
|
||||
const lines = linesRes.ok;
|
||||
const elements: MarkdownElement<Slot>[] = [];
|
||||
while (true) {
|
||||
const line = lines.shift();
|
||||
if (line === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
const element = ((): MarkdownElement<Slot> => {
|
||||
switch (line.type) {
|
||||
case "paragraph": {
|
||||
return {
|
||||
type: "paragraph",
|
||||
paragraph: { spans: line.spans },
|
||||
};
|
||||
}
|
||||
case "header": {
|
||||
return { type: "header", header: { spans: line.spans } };
|
||||
}
|
||||
case "ulistItem": {
|
||||
const items: MarkdownSpan<Slot>[][] = [line.spans];
|
||||
while (true) {
|
||||
const peekLine = lines[0];
|
||||
if (
|
||||
peekLine === undefined
|
||||
|| peekLine.type !== "ulistItem"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
items.push(peekLine.spans);
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
return { type: "ulist", ulist: { items } };
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
elements.push(element);
|
||||
}
|
||||
|
||||
return { type: "ok", ok: { elements, slots: [...slots] } };
|
||||
}
|
||||
|
||||
function parseMarkdownLines<Slot extends string>(
|
||||
markdownString: string,
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownLine<Slot>[], string> {
|
||||
if (markdownString.trim() === "--") {
|
||||
return { type: "ok", ok: [] };
|
||||
}
|
||||
|
||||
const lines = markdownString.split("\n");
|
||||
const dequotedLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
const MARKDOWN_LINE_AFFIX = '"';
|
||||
if (!line.startsWith(MARKDOWN_LINE_AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Line ${String(dequotedLines.length + 1)} of markdown must start with ${MARKDOWN_LINE_AFFIX}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!line.endsWith(MARKDOWN_LINE_AFFIX)) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Line ${String(dequotedLines.length + 1)} of markdown must end with ${MARKDOWN_LINE_AFFIX}`,
|
||||
};
|
||||
}
|
||||
|
||||
const deprefixed = line.substring(MARKDOWN_LINE_AFFIX.length);
|
||||
const dequoted = deprefixed.substring(
|
||||
0,
|
||||
deprefixed.length - MARKDOWN_LINE_AFFIX.length,
|
||||
);
|
||||
|
||||
dequotedLines.push(dequoted);
|
||||
}
|
||||
|
||||
const markdownLines: MarkdownLine<Slot>[] = [];
|
||||
for (const line of dequotedLines) {
|
||||
const markdownLineRes = parseMarkdownLine(line, slots);
|
||||
if (markdownLineRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `On line ${String(markdownLines.length + 1)}:\n${markdownLineRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const markdownLine = markdownLineRes.ok;
|
||||
markdownLines.push(markdownLine);
|
||||
}
|
||||
|
||||
return { type: "ok", ok: markdownLines };
|
||||
}
|
||||
|
||||
function parseMarkdownLine<Slot extends string>(
|
||||
line: string,
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownLine<Slot>, string> {
|
||||
if (line.startsWith("#")) {
|
||||
const elementsRes = parseMarkdownSpans(line.substring(1), slots);
|
||||
|
||||
if (elementsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `While parsing header spans:\n${elementsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const elements = elementsRes.ok;
|
||||
return { type: "ok", ok: { type: "header", spans: elements } };
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
const elementsRes = parseMarkdownSpans(line.substring(1), slots);
|
||||
|
||||
if (elementsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `While parsing ulist item spans:\n${elementsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const elements = elementsRes.ok;
|
||||
return { type: "ok", ok: { type: "ulistItem", spans: elements } };
|
||||
}
|
||||
|
||||
const elementsRes = parseMarkdownSpans(line, slots);
|
||||
if (elementsRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `While parsing elements:\n${elementsRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const elements = elementsRes.ok;
|
||||
return { type: "ok", ok: { type: "paragraph", spans: elements } };
|
||||
}
|
||||
|
||||
function parseMarkdownSpans<Slot extends string>(
|
||||
line: string,
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
if (line.startsWith("#")) {
|
||||
// subheaders may be supported in the future,
|
||||
// so ignoring them or treating them as h1 headers now would be a breaking change when
|
||||
// subheader support is implemented.
|
||||
// making subheaders a compile error for now ensures
|
||||
// all current i18n is backwards-compatible when/if they are implemented
|
||||
return { type: "err", err: "Subheaders are not supported." };
|
||||
}
|
||||
|
||||
const trimmedLine = line.trim();
|
||||
const chars = trimmedLine.split("");
|
||||
const elementsRes = readMarkdownSpans(chars, slots, {
|
||||
inItalic: false,
|
||||
inBold: false,
|
||||
});
|
||||
if (elementsRes.type === "err") {
|
||||
return elementsRes;
|
||||
}
|
||||
|
||||
const elements = elementsRes.ok;
|
||||
return { type: "ok", ok: elements };
|
||||
}
|
||||
|
||||
interface ReadMarkdownSpanCtx {
|
||||
inItalic: boolean;
|
||||
inBold: boolean;
|
||||
}
|
||||
|
||||
function readMarkdownSpans<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownSpanCtx,
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const elements: MarkdownSpan<Slot>[] = [];
|
||||
while (true) {
|
||||
const elementRes = readMarkdownSpan(chars, slots, ctx);
|
||||
if (elementRes.type === "err") {
|
||||
return elementRes;
|
||||
}
|
||||
|
||||
const element = elementRes.ok;
|
||||
if (element.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
elements.push(...element);
|
||||
}
|
||||
|
||||
return { type: "ok", ok: elements };
|
||||
}
|
||||
|
||||
function readMarkdownSpan<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownSpanCtx,
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const [firstChar, secondChar] = chars;
|
||||
if (firstChar === undefined) {
|
||||
return { type: "ok", ok: [] };
|
||||
} else if (firstChar === "<") {
|
||||
return readMarkdownSpanSlot(chars, slots);
|
||||
} else if (firstChar === "[") {
|
||||
return readMarkdownSpanLink(chars, slots, ctx);
|
||||
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) {
|
||||
return readMarkdownSpanBold(chars, slots, ctx);
|
||||
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) {
|
||||
return readMarkdownSpanItalic(chars, slots, ctx);
|
||||
} else {
|
||||
return readMarkdownSpanPlain(chars);
|
||||
}
|
||||
}
|
||||
|
||||
function isInArray<T, U extends T>(value: T, array: readonly U[]): value is U {
|
||||
// SAFETY: this is just an equality check, it is safe to pass in any value
|
||||
return array.includes(value as U);
|
||||
}
|
||||
|
||||
function readMarkdownSpanSlot<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const openAngleRes = expectReadChar(chars, "<");
|
||||
if (openAngleRes.type === "err") {
|
||||
return openAngleRes;
|
||||
}
|
||||
|
||||
const slotNameRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "slot",
|
||||
closingChar: ">",
|
||||
});
|
||||
|
||||
if (slotNameRes.type === "err") {
|
||||
return slotNameRes;
|
||||
}
|
||||
|
||||
const slotName = slotNameRes.ok;
|
||||
if (!isInArray(slotName, slots)) {
|
||||
return { type: "err", err: `Unexpected slot name: ${slotName}` };
|
||||
}
|
||||
|
||||
return { type: "ok", ok: [{ type: "slot", slot: slotName }] };
|
||||
}
|
||||
|
||||
function readMarkdownSpanLink<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownSpanCtx,
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const openSquareRes = expectReadChar(chars, "[");
|
||||
if (openSquareRes.type === "err") {
|
||||
return openSquareRes;
|
||||
}
|
||||
|
||||
const labelElementsRes = readMarkdownSpans(chars, slots, ctx);
|
||||
if (labelElementsRes.type === "err") {
|
||||
return labelElementsRes;
|
||||
}
|
||||
|
||||
const closeSquareRes = expectReadChar(chars, "]");
|
||||
if (closeSquareRes.type === "err") {
|
||||
return closeSquareRes;
|
||||
}
|
||||
|
||||
const labelElements = labelElementsRes.ok;
|
||||
const openParenRes = expectReadChar(chars, "(");
|
||||
if (openParenRes.type === "err") {
|
||||
return openParenRes;
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
dest: SmartDest;
|
||||
newTab: boolean;
|
||||
}
|
||||
|
||||
const linkPropsRes = ((): Result<LinkProps, string> => {
|
||||
if (peekStringEq(chars, "external")) {
|
||||
const externalRes = expectReadString(chars, "external");
|
||||
if (externalRes.type === "err") {
|
||||
return externalRes;
|
||||
}
|
||||
|
||||
const dotRes = expectReadChar(chars, ".");
|
||||
if (dotRes.type === "err") {
|
||||
return dotRes;
|
||||
}
|
||||
|
||||
const tabStringRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link tab",
|
||||
closingChar: ":",
|
||||
});
|
||||
|
||||
if (tabStringRes.type === "err") {
|
||||
return tabStringRes;
|
||||
}
|
||||
|
||||
const tabString = tabStringRes.ok;
|
||||
const newTabRes = ((): Result<boolean, string> => {
|
||||
switch (tabString) {
|
||||
case "new": {
|
||||
return { type: "ok", ok: true };
|
||||
}
|
||||
case "replace": {
|
||||
return { type: "ok", ok: false };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (newTabRes.type === "err") {
|
||||
return newTabRes;
|
||||
}
|
||||
|
||||
const newTab = newTabRes.ok;
|
||||
|
||||
const destRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link dest",
|
||||
closingChar: ")",
|
||||
});
|
||||
|
||||
if (destRes.type === "err") {
|
||||
return destRes;
|
||||
}
|
||||
|
||||
const dest = destRes.ok;
|
||||
const externalDestRes = validateExternalDest(dest);
|
||||
if (externalDestRes.type === "err") {
|
||||
return externalDestRes;
|
||||
}
|
||||
|
||||
const externalDest = externalDestRes.ok;
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: {
|
||||
dest: { type: "external", external: externalDest },
|
||||
newTab,
|
||||
},
|
||||
};
|
||||
} else if (peekStringEq(chars, "internal")) {
|
||||
const internalRes = expectReadString(chars, "internal");
|
||||
if (internalRes.type === "err") {
|
||||
return internalRes;
|
||||
}
|
||||
|
||||
const dotRes = expectReadChar(chars, ".");
|
||||
if (dotRes.type === "err") {
|
||||
return dotRes;
|
||||
}
|
||||
|
||||
const tabStringRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link tab",
|
||||
closingChar: ":",
|
||||
});
|
||||
|
||||
if (tabStringRes.type === "err") {
|
||||
return tabStringRes;
|
||||
}
|
||||
|
||||
const tabString = tabStringRes.ok;
|
||||
const newTabRes = ((): Result<boolean, string> => {
|
||||
switch (tabString) {
|
||||
case "new": {
|
||||
return { type: "ok", ok: true };
|
||||
}
|
||||
case "replace": {
|
||||
return { type: "ok", ok: false };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (newTabRes.type === "err") {
|
||||
return newTabRes;
|
||||
}
|
||||
|
||||
const newTab = newTabRes.ok;
|
||||
|
||||
const destRes = readUntilClosing({
|
||||
chars,
|
||||
elementName: "link dest",
|
||||
closingChar: ")",
|
||||
});
|
||||
|
||||
if (destRes.type === "err") {
|
||||
return destRes;
|
||||
}
|
||||
|
||||
const dest = destRes.ok;
|
||||
const internalDestRes = validateInternalDest(dest);
|
||||
if (internalDestRes.type === "err") {
|
||||
return internalDestRes;
|
||||
}
|
||||
|
||||
const internalDest = internalDestRes.ok;
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: {
|
||||
dest: { type: "internal", internal: internalDest },
|
||||
newTab,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected external or internal link prefix; Found: "${chars.slice(0, 10).join("")}..."`,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
if (linkPropsRes.type === "err") {
|
||||
return linkPropsRes;
|
||||
}
|
||||
|
||||
const { dest, newTab } = linkPropsRes.ok;
|
||||
|
||||
const resolvedLabel: MarkdownSpan[] =
|
||||
labelElements.length === 0 ?
|
||||
[
|
||||
{
|
||||
type: "plain",
|
||||
plain:
|
||||
dest.type === "external" ?
|
||||
dest.external
|
||||
: `${window.location.protocol}${window.location.hostname}${dest.internal.route ?? window.location.pathname}${dest.internal.id === undefined ? "" : `#${dest.internal.id}`}`,
|
||||
},
|
||||
]
|
||||
: labelElements;
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: [
|
||||
{ type: "link", link: { label: resolvedLabel, to: dest, newTab } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function validateExternalDest(dest: string): Result<SmartExternalDest, string> {
|
||||
const HTTPS_PREFIX = "https://";
|
||||
const HTTP_PREFIX = "http://";
|
||||
|
||||
if (dest.startsWith(HTTPS_PREFIX)) {
|
||||
return {
|
||||
type: "ok",
|
||||
ok: `${HTTPS_PREFIX}${dest.substring(HTTPS_PREFIX.length)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (dest.startsWith(HTTP_PREFIX)) {
|
||||
return {
|
||||
type: "ok",
|
||||
ok: `${HTTP_PREFIX}${dest.substring(HTTP_PREFIX.length)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "err",
|
||||
err: `External dest must start with https:// or http://`,
|
||||
};
|
||||
}
|
||||
|
||||
function validateInternalDest(dest: string): Result<SmartInternalDest, string> {
|
||||
const [routeString, id] = dest.split("#");
|
||||
|
||||
const validatedRouteRes = ((): Result<
|
||||
keyof RouteNamedMap | undefined,
|
||||
string
|
||||
> => {
|
||||
if (routeString === undefined || routeString.length === 0) {
|
||||
return { type: "ok", ok: undefined };
|
||||
}
|
||||
|
||||
const route = routes.find((route) => route.path === routeString);
|
||||
if (route === undefined) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Route with ID \`${routeString}\` does not exist`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
// SAFETY: we validated the route exists in the router about
|
||||
ok: route.path as keyof RouteNamedMap,
|
||||
};
|
||||
})();
|
||||
|
||||
if (validatedRouteRes.type === "err") {
|
||||
return validatedRouteRes;
|
||||
}
|
||||
|
||||
const validatedRoute = validatedRouteRes.ok;
|
||||
|
||||
if (validatedRoute !== undefined) {
|
||||
return { type: "ok", ok: { route: validatedRoute, id } };
|
||||
} else if (id !== undefined) {
|
||||
return { type: "ok", ok: { route: validatedRoute, id } };
|
||||
} else {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Either route or ID must be defined for internal dest`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownSpanBold<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownSpanCtx,
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const firstStarRes = expectReadChar(chars, "*");
|
||||
if (firstStarRes.type === "err") {
|
||||
return firstStarRes;
|
||||
}
|
||||
|
||||
const secondStarRes = expectReadChar(chars, "*");
|
||||
if (secondStarRes.type === "err") {
|
||||
return secondStarRes;
|
||||
}
|
||||
|
||||
ctx.inBold = true;
|
||||
const elements: MarkdownSpan<Slot>[] = [];
|
||||
let closed = false;
|
||||
while (true) {
|
||||
const elementRes = readMarkdownSpan(chars, slots, ctx);
|
||||
if (elementRes.type === "err") {
|
||||
return elementRes;
|
||||
}
|
||||
|
||||
const element = elementRes.ok;
|
||||
if (element.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
elements.push(...element);
|
||||
const [firstChar, secondChar] = chars;
|
||||
if (firstChar === "*" && secondChar === "*") {
|
||||
chars.shift();
|
||||
chars.shift();
|
||||
closed = true;
|
||||
break;
|
||||
} else if (firstChar === undefined) {
|
||||
closed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.inBold = !closed;
|
||||
if (closed) {
|
||||
return { type: "ok", ok: [{ type: "bold", bold: elements }] };
|
||||
} else {
|
||||
return {
|
||||
type: "ok",
|
||||
ok: [{ type: "plain", plain: "**" }, ...elements],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownSpanItalic<Slot extends string>(
|
||||
chars: string[],
|
||||
slots: readonly Slot[],
|
||||
ctx: ReadMarkdownSpanCtx,
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
const starRes = expectReadChar(chars, "*");
|
||||
if (starRes.type === "err") {
|
||||
return starRes;
|
||||
}
|
||||
|
||||
ctx.inItalic = true;
|
||||
const elements: MarkdownSpan<Slot>[] = [];
|
||||
let closed = false;
|
||||
while (true) {
|
||||
const elementRes = readMarkdownSpan(chars, slots, ctx);
|
||||
if (elementRes.type === "err") {
|
||||
return elementRes;
|
||||
}
|
||||
|
||||
const element = elementRes.ok;
|
||||
if (element.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
elements.push(...element);
|
||||
const [firstChar] = chars;
|
||||
if (firstChar === "*") {
|
||||
chars.shift();
|
||||
closed = true;
|
||||
break;
|
||||
} else if (firstChar === undefined) {
|
||||
closed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.inItalic = !closed;
|
||||
if (closed) {
|
||||
return { type: "ok", ok: [{ type: "italic", italic: elements }] };
|
||||
} else {
|
||||
return { type: "ok", ok: [{ type: "plain", plain: "*" }, ...elements] };
|
||||
}
|
||||
}
|
||||
|
||||
function readMarkdownSpanPlain<Slot extends string>(
|
||||
chars: string[],
|
||||
): Result<MarkdownSpan<Slot>[], string> {
|
||||
let plain = "";
|
||||
let escaped = false;
|
||||
while (true) {
|
||||
const peek = chars[0];
|
||||
if (peek === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else {
|
||||
if (peek === "\\") {
|
||||
escaped = true;
|
||||
chars.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
peek === "*"
|
||||
|| peek === "<"
|
||||
|| peek === ">"
|
||||
|| peek === "["
|
||||
|| peek === "]"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
plain += peek;
|
||||
chars.shift();
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ok",
|
||||
ok: plain.length > 0 ? [{ type: "plain", plain }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function expectReadChar(
|
||||
chars: string[],
|
||||
expectedChar: string,
|
||||
): Result<void, string> {
|
||||
const nextChar = chars.shift();
|
||||
if (nextChar !== expectedChar) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected: "${expectedChar}"; Found: ${nextChar === undefined ? "undefined" : `"${nextChar}"`}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "ok", ok: undefined };
|
||||
}
|
||||
|
||||
function peekStringEq(chars: string[], expectedString: string): boolean {
|
||||
return chars.slice(0, expectedString.length).join("") === expectedString;
|
||||
}
|
||||
|
||||
function expectReadString(
|
||||
chars: string[],
|
||||
expectedString: string,
|
||||
): Result<void, string> {
|
||||
let foundString: string | undefined = undefined;
|
||||
for (const expectedChar of expectedString) {
|
||||
const nextChar = chars.shift();
|
||||
|
||||
if (nextChar !== undefined) {
|
||||
foundString = (foundString ?? "") + nextChar;
|
||||
}
|
||||
|
||||
if (expectedChar !== nextChar) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Expected: "${expectedString}"; Found: ${foundString === undefined ? "undefined" : `"${foundString}"`}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok", ok: undefined };
|
||||
}
|
||||
|
||||
interface ReadUntilClosingCtx {
|
||||
chars: string[];
|
||||
elementName: string;
|
||||
closingChar: string;
|
||||
}
|
||||
|
||||
function readUntilClosing(ctx: ReadUntilClosingCtx): Result<string, string> {
|
||||
const { chars, elementName, closingChar } = ctx;
|
||||
|
||||
let value = "";
|
||||
let closed = false;
|
||||
let escaped = false;
|
||||
while (true) {
|
||||
const char = chars.shift();
|
||||
if (char === undefined) {
|
||||
closed = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === closingChar && !escaped) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
value += char;
|
||||
}
|
||||
|
||||
if (!closed) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Unclosed ${elementName}: <${value.replaceAll(closingChar, `\\${closingChar}`)}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "ok", ok: value };
|
||||
}
|
||||
|
||||
export function isEmptyMarkdown(markdown: Markdown): boolean {
|
||||
// const [firstLine] = markdown.lines;
|
||||
// if (firstLine === undefined) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// const [firstElement] = firstLine.elements;
|
||||
// if (firstElement === undefined) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// if (firstElement.type === "plain" && firstElement.plain.length === 0) {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// return false;
|
||||
|
||||
return markdown.elements.length === 0;
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import type { Result } from "@/utils/types";
|
||||
import { FluentBundle, FluentResource } from "@fluent/bundle";
|
||||
import { unsafeAsync } from "@/utils/unsafe";
|
||||
import type { Literal, Message, Pattern } from "@fluent/bundle/esm/ast";
|
||||
|
||||
export async function loadFluentBundle(
|
||||
localeId: string,
|
||||
src: string,
|
||||
): Promise<Result<FluentBundle, string>> {
|
||||
const ftlFileResponseRes = await unsafeAsync(() => fetch(src));
|
||||
if (ftlFileResponseRes.type === "err") {
|
||||
return { type: "err", err: `Failed to fetch locale from src: ${src}` };
|
||||
}
|
||||
|
||||
const ftlFileResponse = ftlFileResponseRes.ok;
|
||||
const ftlFileTextRes = await unsafeAsync(() => ftlFileResponse.text());
|
||||
if (ftlFileTextRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to fetch text content of FTL file from src: ${src}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ftlFileText = ftlFileTextRes.ok;
|
||||
const resource = new FluentResource(ftlFileText);
|
||||
|
||||
const bundle = new FluentBundle(localeId);
|
||||
const errors = bundle.addResource(resource);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to add Fluent resource to bundle:\n${errors.join("\n")}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "ok", ok: bundle };
|
||||
}
|
||||
|
||||
export type UncompiledLocale = {
|
||||
[id: string]:
|
||||
| { type: "message"; message: Message }
|
||||
| { type: "subrecord"; subrecord: UncompiledLocale };
|
||||
};
|
||||
|
||||
export function bundleToUncompiledLocaleRecord(
|
||||
bundle: FluentBundle,
|
||||
): Result<UncompiledLocale, string> {
|
||||
const record: UncompiledLocale = {};
|
||||
for (const [id, message] of bundle._messages) {
|
||||
const idChain = id.split("-");
|
||||
let subrecord = record;
|
||||
|
||||
while (true) {
|
||||
const subId = idChain.shift();
|
||||
if (subId === undefined) {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Reached end of message ID chain before terminating for message ID: ${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (idChain.length === 0) {
|
||||
subrecord[subId] = { type: "message", message };
|
||||
break;
|
||||
} else {
|
||||
const maybeSubrecord = (subrecord[subId] ??= {
|
||||
type: "subrecord",
|
||||
subrecord: {},
|
||||
});
|
||||
|
||||
if (maybeSubrecord.type === "subrecord") {
|
||||
subrecord = maybeSubrecord.subrecord;
|
||||
} else {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Found message when expected subrecord for message ID: ${id} @ subId: ${subId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok", ok: record };
|
||||
}
|
||||
|
||||
export type SelectionChain = (Literal | SelectionChain)[];
|
||||
|
||||
export function selectionChainToString(chain: SelectionChain): string {
|
||||
return chain
|
||||
.map((part) => {
|
||||
if ("type" in part) {
|
||||
switch (part.type) {
|
||||
case "str": {
|
||||
return `[${part.value}]`;
|
||||
}
|
||||
case "num": {
|
||||
return `[${String(part.value)};${String(part.precision)}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `(${selectionChainToString(part)})`;
|
||||
})
|
||||
.join("+");
|
||||
}
|
||||
|
||||
export interface PatternVariant {
|
||||
selectionChain: SelectionChain;
|
||||
string: string;
|
||||
}
|
||||
|
||||
export function computeAllVariants(
|
||||
pattern: Pattern,
|
||||
): Result<PatternVariant[], string> {
|
||||
if (typeof pattern === "string") {
|
||||
return { type: "ok", ok: [{ selectionChain: [], string: pattern }] };
|
||||
}
|
||||
|
||||
let variants: PatternVariant[] = [{ selectionChain: [], string: "" }];
|
||||
for (const element of pattern) {
|
||||
if (typeof element === "string") {
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + element,
|
||||
}));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (element.type) {
|
||||
case "select": {
|
||||
const selectVariants: PatternVariant[] = [];
|
||||
for (const selectVariant of element.variants) {
|
||||
const variantComputedRes = computeAllVariants(
|
||||
selectVariant.value,
|
||||
);
|
||||
|
||||
if (variantComputedRes.type === "err") {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Failed to compute select variants:\n${variantComputedRes.err}`,
|
||||
};
|
||||
}
|
||||
|
||||
const variantComputed = variantComputedRes.ok;
|
||||
selectVariants.push(
|
||||
...variantComputed.map(
|
||||
(v): PatternVariant => ({
|
||||
selectionChain: [
|
||||
selectVariant.key,
|
||||
...v.selectionChain,
|
||||
],
|
||||
string: v.string,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
variants = variants.flatMap((variant) =>
|
||||
selectVariants.map(
|
||||
(selectVariant): PatternVariant => ({
|
||||
selectionChain: [
|
||||
...variant.selectionChain,
|
||||
selectVariant.selectionChain,
|
||||
],
|
||||
string: variant.string + selectVariant.string,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case "var": {
|
||||
// used as a stand-in for runtime-provided values
|
||||
const DUMMY_STRING = "$$$";
|
||||
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + DUMMY_STRING,
|
||||
}));
|
||||
|
||||
break;
|
||||
}
|
||||
case "str": {
|
||||
variants = variants.map((variant) => ({
|
||||
selectionChain: variant.selectionChain,
|
||||
string: variant.string + element.value,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
type: "err",
|
||||
err: `Unhandled PatternElement type: ${element.type}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "ok", ok: variants };
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
# Viossa I18n Message Spec
|
||||
|
||||
## Types & Literals
|
||||
There are two types of messages, `string` & `markdown`. Each type is specified by a prefix:
|
||||
- `string` literal (no prefix): `"Hello world!"`
|
||||
- `markdown` literal (`md` prefix): `md "Hello world!"`
|
||||
|
||||
Message literals are made up of lines. Each line is surrounded by quotes.
|
||||
|
||||
## String Literals
|
||||
String literals take exactly one line. They have no special formatting or behavior, exactly what is in the string will be what is displayed:
|
||||
- `"Hello world!"` => Hello world!
|
||||
- `"123 *456* **789**"` => 123 \*456\* \*\*789\*\*
|
||||
|
||||
## Markdown Literals
|
||||
Markdown literals can take any number of lines. Lines are separated by newline characters.
|
||||
```
|
||||
example-markdownMessage = md
|
||||
"Line 1"
|
||||
"Line 2"
|
||||
"Line 3"
|
||||
```
|
||||
|
||||
They can also consist of a single line:
|
||||
```
|
||||
example-markdownMessage = md "Line 1"
|
||||
```
|
||||
|
||||
A special sigil exists for denoting that no lines exist:
|
||||
```
|
||||
example-markdownMessage = md --
|
||||
```
|
||||
|
||||
### Line Types
|
||||
- Paragraph: `Example`
|
||||
- Header: `# Example` (subheaders are not supported)
|
||||
- Unordered List Item: `- Example`
|
||||
|
||||
### Line Features
|
||||
- Italic: `*Example*` => *Example*
|
||||
- Bold: `**Example**` => **Example**
|
||||
- Bold + Italic: `***Example***` => ***Example***
|
||||
- Links: `[Example](external.new:https://example.com/)` => [Example](https://example.com/)
|
||||
- Slots: `<example>`
|
||||
|
||||
Characters used for line feature syntax can be escaped to remove their effect and place the raw character in the string: `\*Example\*` => \*Example\*
|
||||
|
||||
### Links
|
||||
Links are made up of 4 components:
|
||||
```
|
||||
[Example](external.new:https://example.com/)
|
||||
^^^^^^^ ^^^^^^^^ ^^^ ^^^^^^^^^^^^^^^^^^^^
|
||||
name type tab destination
|
||||
```
|
||||
|
||||
`name` is the text displayed to the user on the webpage. It is optional; If blank, it will display the destination directly to the user.
|
||||
|
||||
`type` can be either `internal` or `external`, and changes what is deemed a valid `destination`.
|
||||
|
||||
If `tab` is `new`, the link will open the `destination` in a new tab. If `tab` is `replace`, it will open in the current tab.
|
||||
|
||||
`destination` is where the link will take the user when clicked. Its value depends on the value of `type` as follows:
|
||||
- If `type` is `external`: `destination` is any link starting with `http://` or `https://`
|
||||
- `http://example.com/`
|
||||
- `https://google.com/`
|
||||
- `https://viossa.net/`
|
||||
- If `type` is `internal`: `destination` consists of a `route` and `id`, in any of the following patterns:
|
||||
- `route`-only: brings the user to another route on the website
|
||||
- `/`
|
||||
- `/resources`
|
||||
- `/kotoba`
|
||||
- `/discord/rules`
|
||||
- `id`-only: jumps the user to a specific element ID on the current route
|
||||
- `#top`
|
||||
- `#header`
|
||||
- `#rule-1`
|
||||
- `route` with `id`: brings the user to another route and jumps to an element ID on that page
|
||||
- `/discord/rules#rule-1`
|
||||
- `/#top`
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import {
|
||||
string,
|
||||
markdown,
|
||||
record,
|
||||
type InferLocaleFromConfig,
|
||||
type LocaleConfig,
|
||||
type ConfigString,
|
||||
} from "@/new-i18n-lib/config";
|
||||
|
||||
function plainString(): ConfigString<object> {
|
||||
return string({ placeables: {} });
|
||||
}
|
||||
|
||||
const homeSectionConfig = { title: plainString(), body: plainString() };
|
||||
|
||||
const imageConfig = { alt: plainString() };
|
||||
export interface Image extends InferLocaleFromConfig<typeof imageConfig> {}
|
||||
|
||||
const buttonConfig = { label: plainString() };
|
||||
|
||||
const resourceConfig = <ButtonKey extends string>(buttonKeys: ButtonKey[]) => ({
|
||||
title: plainString(),
|
||||
subtitle: plainString(),
|
||||
desc: plainString(),
|
||||
buttons: record(buttonKeys, () => buttonConfig),
|
||||
});
|
||||
|
||||
const discordRuleConfig = {
|
||||
overview: {
|
||||
text: markdown({
|
||||
placeables: {},
|
||||
features: { bold: true, italic: true, link: true, slots: {} },
|
||||
}),
|
||||
subtext: markdown({
|
||||
placeables: {},
|
||||
features: { bold: true, italic: true, link: true, slots: {} },
|
||||
}),
|
||||
},
|
||||
section: {
|
||||
header: string({ placeables: { ruleNumber: { type: "number" } } }),
|
||||
body: markdown({
|
||||
placeables: {},
|
||||
features: {
|
||||
bold: true,
|
||||
header: true,
|
||||
italic: true,
|
||||
link: true,
|
||||
slots: {},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const localeConfig = {
|
||||
localeName: plainString(),
|
||||
vilanticLangs: record(["viossa", "wodox"], () => plainString()),
|
||||
navbar: record(["whatIsViossa", "resources", "kotoba"], () =>
|
||||
plainString(),
|
||||
),
|
||||
home: {
|
||||
sections: record(
|
||||
["whatIsViossa", "historyOfViossa", "community"],
|
||||
() => homeSectionConfig,
|
||||
),
|
||||
images: record(["viossaFlag"], () => imageConfig),
|
||||
},
|
||||
resources: {
|
||||
title: plainString(),
|
||||
resources: { discord: resourceConfig(["join", "rules"]) },
|
||||
images: record(["discordLogo"], () => imageConfig),
|
||||
},
|
||||
kotoba: { title: plainString(), searchHelp: plainString() },
|
||||
discord: {
|
||||
rulesPage: {
|
||||
title: plainString(),
|
||||
overview: { title: plainString(), help: plainString() },
|
||||
rules: record(
|
||||
[
|
||||
"noTranslation",
|
||||
"lfsv",
|
||||
"viossaOnlyChats",
|
||||
"sfw",
|
||||
"respectOthers",
|
||||
"respectStaff",
|
||||
"controversialTopics",
|
||||
],
|
||||
() => discordRuleConfig,
|
||||
),
|
||||
},
|
||||
},
|
||||
} as const satisfies LocaleConfig;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import type { VilanticId } from "./vilantic";
|
||||
|
||||
export interface Greeting {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
author: string;
|
||||
lang: VilanticId;
|
||||
}
|
||||
|
||||
export const GREETINGS = [
|
||||
{
|
||||
title: "BRÅTULA VIOSSA.NET MÅDE",
|
||||
subtitle: "Hadjiplas per lera para Viossa – glossa fu vi",
|
||||
author: "Jez",
|
||||
lang: "viossa",
|
||||
},
|
||||
{
|
||||
title: "akka po viossa.net!",
|
||||
subtitle: "kenomasufobo o gen wi tropos o viosox",
|
||||
author: "Tetro",
|
||||
lang: "wodox",
|
||||
},
|
||||
] as const satisfies Greeting[];
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import { type InferLocaleFromConfig } from "@/new-i18n-lib/config";
|
||||
import {
|
||||
bundleToUncompiledLocaleRecord,
|
||||
loadFluentBundle,
|
||||
} from "@/new-i18n-lib/setup";
|
||||
import { localeConfig } from "./config";
|
||||
import type { Result } from "@/utils/types";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { computed, type DeepReadonly } from "vue";
|
||||
import { type } from "arktype";
|
||||
import enUsFtlSrc from "@/assets/locale/en_US.ftl";
|
||||
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
|
||||
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
|
||||
import type { FluentBundle } from "@fluent/bundle";
|
||||
import { compileLocale } from "@/new-i18n-lib/compile";
|
||||
|
||||
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
|
||||
|
||||
export type LocaleId = typeof LocaleId.infer;
|
||||
export const LocaleId = type.enumerated(...LOCALE_IDS);
|
||||
|
||||
export const DEFAULT_LOCALE_ID = "en-US" satisfies LocaleId;
|
||||
|
||||
// users could manually edit localStorage to make this value anything, so we need to validate it
|
||||
const localStorageLocaleId = useLocalStorage<unknown>(
|
||||
"localeId",
|
||||
DEFAULT_LOCALE_ID,
|
||||
);
|
||||
|
||||
export const localeId = computed({
|
||||
get: (): LocaleId => {
|
||||
const localeIdRes = LocaleId(localStorageLocaleId.value);
|
||||
if (localeIdRes instanceof type.errors) {
|
||||
// if invalid LocaleId, reset to default
|
||||
localStorageLocaleId.value = DEFAULT_LOCALE_ID;
|
||||
return DEFAULT_LOCALE_ID;
|
||||
}
|
||||
|
||||
// else return user's selection
|
||||
const localeId = localeIdRes;
|
||||
return localeId;
|
||||
},
|
||||
// custom setter to ensure it is only set to a valid LocaleId by our code
|
||||
// (since the localStorage ref is typed as `unknown`, it can be set to any value)
|
||||
set: (id: LocaleId) => {
|
||||
localStorageLocaleId.value = id;
|
||||
},
|
||||
});
|
||||
|
||||
export interface Locale extends InferLocaleFromConfig<typeof localeConfig> {}
|
||||
|
||||
async function loadLocale(
|
||||
localeId: LocaleId,
|
||||
localeFtlSrc: string,
|
||||
): Promise<Result<FluentBundle, string>> {
|
||||
const bundleRes = await loadFluentBundle(localeId, localeFtlSrc);
|
||||
if (bundleRes.type === "err") {
|
||||
return bundleRes;
|
||||
}
|
||||
|
||||
const bundle = bundleRes.ok;
|
||||
return { type: "ok", ok: bundle };
|
||||
}
|
||||
|
||||
interface SetupLocaleFallback {
|
||||
bundle: FluentBundle;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function setupLocale(
|
||||
localeBundle: FluentBundle,
|
||||
fallback: SetupLocaleFallback | undefined,
|
||||
): Result<Locale, string> {
|
||||
const fallbackBundle = fallback?.bundle;
|
||||
const fallbackLocale = fallback?.locale;
|
||||
|
||||
const maybeFallbackedBundle = (() => {
|
||||
if (fallbackBundle === undefined) {
|
||||
return localeBundle;
|
||||
}
|
||||
|
||||
const localeMessageIds = new Set(localeBundle._messages.keys());
|
||||
const fallbackMessageIds = new Set(fallbackBundle._messages.keys());
|
||||
|
||||
const missingMessageIds =
|
||||
fallbackMessageIds.difference(localeMessageIds);
|
||||
|
||||
for (const id of missingMessageIds) {
|
||||
const fallbackMessage = fallbackBundle._messages.get(id);
|
||||
if (fallbackMessage) {
|
||||
localeBundle._messages.set(id, fallbackMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return localeBundle;
|
||||
})();
|
||||
|
||||
const uncompiledLocaleRecordRes = bundleToUncompiledLocaleRecord(
|
||||
maybeFallbackedBundle,
|
||||
);
|
||||
if (uncompiledLocaleRecordRes.type === "err") {
|
||||
return uncompiledLocaleRecordRes;
|
||||
}
|
||||
|
||||
const uncompiledLocaleRecord = uncompiledLocaleRecordRes.ok;
|
||||
|
||||
const localeRes = compileLocale({
|
||||
config: localeConfig,
|
||||
bundle: maybeFallbackedBundle,
|
||||
uncompiled: uncompiledLocaleRecord,
|
||||
fallback: fallbackLocale,
|
||||
messageIdChain: [],
|
||||
});
|
||||
|
||||
console.error(localeRes.errors);
|
||||
|
||||
const locale = localeRes.locale;
|
||||
return { type: "ok", ok: locale };
|
||||
}
|
||||
|
||||
function unwrap<T, E>(result: Result<T, E>): T {
|
||||
switch (result.type) {
|
||||
case "ok": {
|
||||
return result.ok;
|
||||
}
|
||||
case "err": {
|
||||
throw new Error(String(result.err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepReadonly<T>(value: T): DeepReadonly<T> {
|
||||
// SAFETY: we're just making an immutable view to the type, this isn't dangerous
|
||||
return value as DeepReadonly<T>;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCALE_BUNDLE = unwrap(await loadLocale("en-US", enUsFtlSrc));
|
||||
const DEFAULT_LOCALE = unwrap(setupLocale(DEFAULT_LOCALE_BUNDLE, undefined));
|
||||
|
||||
const doItAllForLocale = async (
|
||||
localeId: LocaleId,
|
||||
localeFtlSrc: string,
|
||||
): Promise<DeepReadonly<Locale>> =>
|
||||
deepReadonly(
|
||||
unwrap(
|
||||
setupLocale(unwrap(await loadLocale(localeId, localeFtlSrc)), {
|
||||
bundle: DEFAULT_LOCALE_BUNDLE,
|
||||
locale: DEFAULT_LOCALE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [vpVl, wpVl] = await Promise.all([
|
||||
doItAllForLocale("vp-VL", vpVlFtlSrc),
|
||||
doItAllForLocale("wp-VL", wpVlFtlSrc),
|
||||
]);
|
||||
|
||||
const localeIdToLocale = {
|
||||
"en-US": deepReadonly(DEFAULT_LOCALE),
|
||||
"vp-VL": vpVl,
|
||||
"wp-VL": wpVl,
|
||||
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
|
||||
|
||||
export interface UseLocaleOptions {
|
||||
locale?: LocaleId;
|
||||
}
|
||||
|
||||
export const useLocale = (opt: UseLocaleOptions = {}) =>
|
||||
computed<DeepReadonly<Locale>>(() => {
|
||||
const localLocaleId = opt.locale ?? localeId.value;
|
||||
return localeIdToLocale[localLocaleId];
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import viossaFlag from "@/assets/flag_vp.webp";
|
||||
import wodoxFlag from "@/assets/flag_wp.webp";
|
||||
|
||||
export type VilanticId = "viossa" | "wodox";
|
||||
|
||||
export const VILANTIC_ID_TO_FLAG = {
|
||||
viossa: viossaFlag,
|
||||
wodox: wodoxFlag,
|
||||
} as const satisfies Record<VilanticId, string>;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
|
||||
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
|
||||
import { useLocale } from "@/new-i18n";
|
||||
import { computed } from "vue";
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
const pageI18n = computed(() => locale.value.discord.rulesPage);
|
||||
const rulesI18n = computed(() => pageI18n.value.rules);
|
||||
|
||||
const RULE_ORDER = [
|
||||
"noTranslation",
|
||||
"lfsv",
|
||||
"viossaOnlyChats",
|
||||
"sfw",
|
||||
"respectOthers",
|
||||
"respectStaff",
|
||||
"controversialTopics",
|
||||
] as const satisfies (keyof typeof rulesI18n.value)[];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="section">
|
||||
<h1 class="title">
|
||||
{{ pageI18n.title() }}
|
||||
</h1>
|
||||
</section>
|
||||
<section class="section content">
|
||||
<h2>{{ pageI18n.overview.title() }}</h2>
|
||||
<blockquote>
|
||||
{{ pageI18n.overview.help() }}
|
||||
</blockquote>
|
||||
<ol :style="{ display: 'flex', flexDirection: 'column' }">
|
||||
<DiscordRuleOverview
|
||||
v-for="(id, index) in RULE_ORDER"
|
||||
:key="index"
|
||||
:rule-number="index + 1"
|
||||
:overview="rulesI18n[id].overview" />
|
||||
</ol>
|
||||
</section>
|
||||
<DiscordRuleSection
|
||||
v-for="(id, index) in RULE_ORDER"
|
||||
:key="index"
|
||||
:section="rulesI18n[id].section"
|
||||
:rule-number="index + 1" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
|
||||
import { GREETINGS, type Greeting } from "@/new-i18n/greeting";
|
||||
import { VILANTIC_ID_TO_FLAG } from "@/new-i18n/vilantic";
|
||||
import { randomElement } from "@/utils/random";
|
||||
import { computed } from "vue";
|
||||
import flakkaImg from "@/assets/flakka.png";
|
||||
import { useLocale, type Locale } from "@/new-i18n";
|
||||
import type * as i18n from "@/new-i18n/config";
|
||||
|
||||
interface SectionConfig {
|
||||
id: keyof Locale["home"]["sections"];
|
||||
image?: keyof typeof imagesI18n.value;
|
||||
}
|
||||
|
||||
const SECTION_CONFIGS = [
|
||||
{ id: "whatIsViossa", image: "viossaFlag" },
|
||||
{ id: "historyOfViossa", image: "viossaFlag" },
|
||||
{ id: "community" },
|
||||
] as const satisfies SectionConfig[];
|
||||
|
||||
const locale = useLocale();
|
||||
const homeI18n = computed(() => locale.value.home);
|
||||
|
||||
interface ImageI18n {
|
||||
src: string;
|
||||
metadata: i18n.Image;
|
||||
}
|
||||
|
||||
const imagesI18n = computed(() => {
|
||||
const imagesI18n = homeI18n.value.images;
|
||||
|
||||
return {
|
||||
viossaFlag: { src: flakkaImg, metadata: imagesI18n.viossaFlag },
|
||||
} as const satisfies Record<string, ImageI18n>;
|
||||
});
|
||||
|
||||
const greeting: Greeting = randomElement(GREETINGS);
|
||||
|
||||
const sectionsI18n = computed(() =>
|
||||
SECTION_CONFIGS.map(({ id, image }: SectionConfig) => ({
|
||||
text: homeI18n.value.sections[id],
|
||||
image: image && imagesI18n.value[image],
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="hero has-background-primary-soft is-primary">
|
||||
<div
|
||||
class="hero-body"
|
||||
style="padding-top: 3.75rem; padding-bottom: 3rem">
|
||||
<div class="title has-text-text-bold">{{ greeting.title }}</div>
|
||||
<div class="subtitle has-text-text-bold mb-4">
|
||||
{{ greeting.subtitle }}
|
||||
</div>
|
||||
<div
|
||||
class="subtitle is-size-6 is-flex is-flex-direction-row is-align-items-center is-gap-1 has-text-text-bold">
|
||||
— {{ greeting.author }} ({{
|
||||
locale.vilanticLangs[greeting.lang]()
|
||||
}})
|
||||
<figure class="image is-32x32">
|
||||
<img :src="VILANTIC_ID_TO_FLAG[greeting.lang]" />
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section container">
|
||||
<HomeSectionWrapper
|
||||
v-for="(sectioni18n, index) in sectionsI18n"
|
||||
:key="index"
|
||||
:title="sectioni18n.text.title()"
|
||||
:text="sectioni18n.text.body()"
|
||||
:image="sectioni18n.image?.src"
|
||||
:alt="sectioni18n.image?.metadata.alt()"
|
||||
:reverse="index % 2 !== 0" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useLocale } from "@/new-i18n";
|
||||
|
||||
const locale = useLocale();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="section">
|
||||
<h1 class="title">{{ locale.kotoba.title() }}</h1>
|
||||
</section>
|
||||
|
||||
<section class="section container">
|
||||
<div class="notification is-info block">
|
||||
<p>{{ locale.kotoba.searchHelp() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="block is-flex is-flex-direction-row is-gap-2">
|
||||
<input class="input" type="text" />
|
||||
</div>
|
||||
|
||||
<div class="container"></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import LearningResourceWrapper, {
|
||||
type ResourceButton,
|
||||
} from "@/components/molecules/LearningResourceWrapper.vue";
|
||||
import { useLocale, type Locale } from "@/new-i18n";
|
||||
import type * as i18n from "@/new-i18n/config";
|
||||
import { ignore } from "@/utils/ignore";
|
||||
import { computed } from "vue";
|
||||
import discordImg from "@/assets/discord.png";
|
||||
|
||||
interface ResourceConfig {
|
||||
id: keyof Locale["resources"]["resources"];
|
||||
image?: keyof typeof imagesI18n.value;
|
||||
}
|
||||
|
||||
const RESOURCE_CONFIGS = [
|
||||
{ id: "discord", image: "discordLogo" },
|
||||
] as const satisfies ResourceConfig[];
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
const pageI18n = computed(() => locale.value.resources);
|
||||
|
||||
interface ImageI18n {
|
||||
src: string;
|
||||
metadata: i18n.Image;
|
||||
}
|
||||
|
||||
const imagesI18n = computed(() => {
|
||||
const imagesI18n = pageI18n.value.images;
|
||||
|
||||
return {
|
||||
discordLogo: { src: discordImg, metadata: imagesI18n.discordLogo },
|
||||
} as const satisfies Record<string, ImageI18n>;
|
||||
});
|
||||
|
||||
const resourcesI18n = computed(() =>
|
||||
RESOURCE_CONFIGS.map(
|
||||
({ id, image }: ResourceConfig) =>
|
||||
({
|
||||
id,
|
||||
text: pageI18n.value.resources[id],
|
||||
image: image && imagesI18n.value[image],
|
||||
}) as const,
|
||||
),
|
||||
);
|
||||
|
||||
const computeButtons = (
|
||||
id: keyof Locale["resources"]["resources"],
|
||||
): ResourceButton[] => {
|
||||
// will warn us if a new variant is added that isn't handled, and so we should add a switch
|
||||
// once we have a switch statement, this won't be needed as that will check for exhaustiveness
|
||||
ignore<"discord">(id);
|
||||
|
||||
const buttons = pageI18n.value.resources[id].buttons;
|
||||
return [
|
||||
{
|
||||
link: {
|
||||
to: {
|
||||
type: "external",
|
||||
external: "https://discord.viossa.net",
|
||||
},
|
||||
newTab: true,
|
||||
},
|
||||
label: buttons.join.label(),
|
||||
style: { color: "primary" },
|
||||
},
|
||||
{
|
||||
link: {
|
||||
to: { type: "internal", internal: { route: "/discord/rules" } },
|
||||
},
|
||||
label: buttons.rules.label(),
|
||||
style: { color: "warning", outlined: true },
|
||||
},
|
||||
];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="section">
|
||||
<h1 class="title">{{ locale.resources.title() }}</h1>
|
||||
</section>
|
||||
|
||||
<section class="section container">
|
||||
<LearningResourceWrapper
|
||||
v-for="(resourceI18n, index) in resourcesI18n"
|
||||
:key="index"
|
||||
:title="resourceI18n.text.title()"
|
||||
:subtitle="resourceI18n.text.subtitle()"
|
||||
:desc="resourceI18n.text.desc()"
|
||||
:image="
|
||||
resourceI18n.image && {
|
||||
src: resourceI18n.image.src,
|
||||
alt: resourceI18n.image.metadata.alt(),
|
||||
}
|
||||
"
|
||||
:buttons="computeButtons(resourceI18n.id)" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import { routes, handleHotUpdate } from "vue-router/auto-routes";
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
if (import.meta.hot) {
|
||||
handleHotUpdate(router);
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import axios from "axios";
|
||||
import SERVER_URL from "./web.service"
|
||||
import type { SearchResult } from "@repo/common/dto";
|
||||
|
||||
export default class KotobaService {
|
||||
|
||||
public static search(search_term:string){
|
||||
return axios.get<SearchResult>(`${SERVER_URL}/search`,
|
||||
{params:{ search_term }});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/* Establish constants here */
|
||||
export default class WebConstants {
|
||||
static SERVER_URL: string = "http://localhost:1225"
|
||||
}
|
||||
5
apps/vdn-static/src/shims-vue.d.ts
vendored
|
|
@ -1,5 +0,0 @@
|
|||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
26
apps/vdn-static/src/typed-router.d.ts
vendored
|
|
@ -1,26 +0,0 @@
|
|||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
/**
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||
'/discord/rules': RouteRecordInfo<'/discord/rules', '/discord/rules', Record<never, never>, Record<never, never>>,
|
||||
'/kotoba': RouteRecordInfo<'/kotoba', '/kotoba', Record<never, never>, Record<never, never>>,
|
||||
'/resources': RouteRecordInfo<'/resources', '/resources', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export type CssClass = string | false | null | undefined | CssClass[];
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters, @typescript-eslint/no-unused-vars
|
||||
export function ignore<T>(_: T): void {}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export function randomMaybeElement<Elements extends unknown[]>(
|
||||
elements: Elements,
|
||||
): Elements[number] | undefined {
|
||||
const index = Math.floor(Math.random() * elements.length);
|
||||
return elements[index];
|
||||
}
|
||||
|
||||
export function randomElement<Elements extends [unknown, ...unknown[]]>(
|
||||
elements: Elements,
|
||||
): Elements[number] {
|
||||
// SAFETY: because there is always at least one element, undefined will never be returned
|
||||
return randomMaybeElement(elements) as Elements[number];
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import type { RouteNamedMap } from "vue-router/auto-routes";
|
||||
|
||||
export type SmartDest =
|
||||
| { type: "internal"; internal: SmartInternalDest }
|
||||
| { type: "external"; external: SmartExternalDest };
|
||||
|
||||
export type SmartInternalDest =
|
||||
| { route: keyof RouteNamedMap; id?: string }
|
||||
| { route?: keyof RouteNamedMap; id: string };
|
||||
|
||||
export type SmartExternalDest = `https://${string}` | `http://${string}`;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export type DeepPartial<T extends object> =
|
||||
T extends Function ? T
|
||||
: { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
|
||||
|
||||
export type Prettify<T> = T extends object ? { [K in keyof T]: T[K] } & {} : T;
|
||||
export type Value<T> = T[keyof T];
|
||||
|
||||
export type Result<T, E> = { type: "ok"; ok: T } | { type: "err"; err: E };
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import type { Result } from "./types";
|
||||
|
||||
export async function unsafeAsync<R>(
|
||||
f: () => Promise<R>,
|
||||
): Promise<Result<R, unknown>> {
|
||||
try {
|
||||
return { type: "ok", ok: await f() };
|
||||
} catch (e) {
|
||||
return { type: "err", err: e };
|
||||
}
|
||||
}
|
||||
2
apps/vdn-static/src/vite-env.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="unplugin-vue-router/client" />
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
],
|
||||
"rootDir": "src",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"strictTemplates": true,
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
],
|
||||
"exclude": [
|
||||
"./eslint.config.js"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "NodeNext",
|
||||
"skipLibCheck": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueRouter from "unplugin-vue-router/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vueRouter({ root: "src", routesFolder: "pages" }), vue({})],
|
||||
resolve: { alias: { "@": path.resolve(import.meta.dirname, "src") } },
|
||||
server: { port: 1224 },
|
||||
assetsInclude: ["**/*.ftl"],
|
||||
});
|
||||
36
pnpm-lock.yaml
generated
|
|
@ -23,6 +23,9 @@ importers:
|
|||
'@repo/common':
|
||||
specifier: workspace:*
|
||||
version: link:../../libs/common
|
||||
cors:
|
||||
specifier: ^2.8.6
|
||||
version: 2.8.6
|
||||
csv-parser:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
|
|
@ -54,6 +57,9 @@ importers:
|
|||
'@total-typescript/ts-reset':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
'@types/cors':
|
||||
specifier: ^2.8.19
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
|
|
@ -789,6 +795,9 @@ packages:
|
|||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
|
|
@ -1231,6 +1240,10 @@ packages:
|
|||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -1635,16 +1648,18 @@ packages:
|
|||
|
||||
glob@10.4.5:
|
||||
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@11.0.2:
|
||||
resolution: {integrity: sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==}
|
||||
engines: {node: 20 || >=22}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
|
|
@ -2149,6 +2164,10 @@ packages:
|
|||
nth-check@2.1.1:
|
||||
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2257,6 +2276,7 @@ packages:
|
|||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
|
|
@ -2538,7 +2558,7 @@ packages:
|
|||
tar@6.2.1:
|
||||
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
|
||||
engines: {node: '>=10'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
tar@7.4.3:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
|
|
@ -2746,6 +2766,7 @@ packages:
|
|||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
vary@1.1.2:
|
||||
|
|
@ -3375,6 +3396,10 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 22.15.31
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 22.15.31
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
|
@ -3908,6 +3933,11 @@ snapshots:
|
|||
|
||||
cookie@0.7.2: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
vary: 1.1.2
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
@ -4877,6 +4907,8 @@ snapshots:
|
|||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
|
|
|
|||