Merge branch 'nouns-nb-p' into 'main'

nouns nonbinary column and responsive design

See merge request PronounsPage/PronounsPage!541
This commit is contained in:
Andrea Vos 2025-01-18 10:59:57 +00:00
commit 459c1629fb
27 changed files with 306 additions and 254 deletions

View File

@ -262,6 +262,30 @@ form[inert] {
}
}
.btn-concise {
white-space: nowrap;
}
@include media-breakpoint-up('md', $grid-breakpoints) {
.list-btn-concise {
min-width: 3rem;
li {
height: 2.5rem;
}
}
.btn-concise {
position: absolute;
.btn-label {
display: none;
}
&:hover .btn-label {
display: inline;
}
}
}
.graph {
width: 100%;
height: 400px;

View File

@ -1,5 +1,5 @@
<template>
<Table :data="abuseReports" :columns="abuseReports.length && abuseReports[0].snapshot ? 5 : 4">
<Table :data="abuseReports">
<template #header>
<div class="bold text-nowrap">
Suspicious account

View File

@ -106,7 +106,7 @@ const remove = async (entry: InclusiveEntry): Promise<void> => {
@submit-clicked="form?.focus()"
/>
<Table ref="dictionarytable" :data="visibleEntries" :columns="3" :marked="(el) => !el.approved" fixed>
<Table ref="dictionarytable" :data="visibleEntries" :marked="(el) => !el.approved" fixed>
<template #header>
<div class="bold text-nowrap">
<Icon v="comment-times" />
@ -369,28 +369,4 @@ const remove = async (entry: InclusiveEntry): Promise<void> => {
/ 1fr 1fr 2.5fr 3em;
}
}
.btn-concise {
white-space: nowrap;
}
@include media-breakpoint-up('md', $grid-breakpoints) {
.list-btn-concise {
min-width: 3rem;
li {
height: 2.5rem;
}
}
.btn-concise {
position: absolute;
.btn-label {
display: none;
}
&:hover .btn-label {
display: inline;
}
}
}
</style>

View File

@ -11,7 +11,6 @@ type VPage = {
const props = withDefaults(defineProps<{
data: T[];
columns: number;
perPage?: number;
marked?: (element: T) => boolean;
fixed?: boolean;

View File

@ -128,7 +128,7 @@ const year = buildCalendar(runtimeConfig.public.baseUrl).getCurrentYear()!;
@submit-clicked="form?.focus()"
/>
<Table ref="dictionarytable" :data="visibleEntries" :columns="1" fixed :marked="(el) => !el.approved">
<Table ref="dictionarytable" :data="visibleEntries" fixed :marked="(el) => !el.approved">
<template #row="s">
<template v-if="s">
<div>
@ -231,29 +231,6 @@ const year = buildCalendar(runtimeConfig.public.baseUrl).getCurrentYear()!;
}
}
.btn-concise {
white-space: nowrap;
}
@include media-breakpoint-up('md', $grid-breakpoints) {
.list-btn-concise {
min-width: 3rem;
li {
height: 2.5rem;
}
}
.btn-concise {
position: absolute;
.btn-label {
display: none;
}
&:hover .btn-label {
display: inline;
}
}
}
@include media-breakpoint-down('md', $grid-breakpoints) {
.cell-wide {
min-width: 90vw;

View File

@ -1,9 +1,6 @@
<script setup lang="ts">
import type { ComponentExposed } from 'vue-component-type-helpers';
import type Table from '~/components/Table.vue';
import type NounsSubmitForm from '~/components/nouns/NounsSubmitForm.vue';
import { Noun, genders } from '~/src/classes.ts';
import { Noun } from '~/src/classes.ts';
import type { NounRaw } from '~/src/classes.ts';
import { buildDict } from '~/src/helpers.ts';
@ -15,7 +12,7 @@ const { $translator: translator } = useNuxtApp();
const config = useConfig();
const filter = useFilterWithCategory();
const dictionarytable = useTemplateRef<ComponentExposed<typeof Table>>('dictionarytable');
const dictionarytable = useTemplateRef('dictionarytable');
watch(filter, () => {
if (dictionarytable.value) {
dictionarytable.value.reset();
@ -113,84 +110,71 @@ defineExpose({ loadNouns });
@submit-clicked="form?.focus()"
/>
<Table ref="dictionarytable" :data="visibleNouns" :columns="3" :marked="(el) => !el.approved" fixed>
<template #header>
<div v-for="gender in genders" :key="gender" class="d-none d-md-block bold">
<NounsGenderLabel :gender="gender" />
</div>
</template>
<template #row="{ el: noun }">
<NounsDictionaryEntry
:noun="noun"
:base="noun.base && nouns[noun.base] ? nouns[noun.base] : undefined"
>
<template #buttons>
<ul class="d-flex flex-wrap flex-md-column list-unstyled list-btn-concise mb-0">
<template v-if="$isGranted('nouns')">
<li v-if="noun.author" class="small">
<nuxt-link
:to="`/@${noun.author}`"
class="btn btn-concise btn-outline-dark btn-sm m-1"
>
<Icon v="user" />
<span class="btn-label">
<T>crud.author</T><T>quotation.colon</T>
@{{ noun.author }}
</span>
</nuxt-link>
</li>
<li v-if="!noun.approved">
<button class="btn btn-concise btn-success btn-sm m-1" @click="approve(noun)">
<Icon v="check" />
<span class="btn-label"><T>crud.approve</T></span>
</button>
</li>
<li v-else @click="hide(noun)">
<button class="btn btn-concise btn-outline-secondary btn-sm m-1">
<Icon v="times" />
<span class="btn-label"><T>crud.hide</T></span>
</button>
</li>
<li>
<button class="btn btn-concise btn-outline-danger btn-sm m-1" @click="remove(noun)">
<Icon v="trash" />
<span class="btn-label"><T>crud.remove</T></span>
</button>
</li>
</template>
<li>
<button class="btn btn-concise btn-outline-primary btn-sm m-1" @click="edit(noun)">
<Icon v="pen" />
<span class="btn-label">
<T v-if="$isGranted('nouns')">crud.edit</T>
<T v-else>nouns.edit</T>
</span>
</button>
</li>
<li>
<a
:href="`/api/nouns/${noun.id}.png`"
target="_blank"
rel="noopener"
class="btn btn-concise btn-outline-primary btn-sm m-1"
>
<Icon v="image" />
<span class="btn-label">
<T>nouns.image</T>
</span>
</a>
</li>
</ul>
<NounsTable
ref="dictionarytable"
:class="[config.nouns.nonbinary ? 'nouns-table-nonbinary' : '']"
:nouns="visibleNouns"
:marked="(el) => !el.approved"
>
<template #buttons="{ noun }">
<ul class="d-flex flex-wrap flex-md-column list-unstyled list-btn-concise mb-0">
<template v-if="$isGranted('nouns')">
<li v-if="noun.author" class="small">
<nuxt-link
:to="`/@${noun.author}`"
class="btn btn-concise btn-outline-dark btn-sm m-1"
>
<Icon v="user" />
<span class="btn-label">
<T>crud.author</T><T>quotation.colon</T>
@{{ noun.author }}
</span>
</nuxt-link>
</li>
<li v-if="!noun.approved">
<button class="btn btn-concise btn-success btn-sm m-1" @click="approve(noun)">
<Icon v="check" />
<span class="btn-label"><T>crud.approve</T></span>
</button>
</li>
<li v-else @click="hide(noun)">
<button class="btn btn-concise btn-outline-secondary btn-sm m-1">
<Icon v="times" />
<span class="btn-label"><T>crud.hide</T></span>
</button>
</li>
<li>
<button class="btn btn-concise btn-outline-danger btn-sm m-1" @click="remove(noun)">
<Icon v="trash" />
<span class="btn-label"><T>crud.remove</T></span>
</button>
</li>
</template>
</NounsDictionaryEntry>
<li>
<button class="btn btn-concise btn-outline-primary btn-sm m-1" @click="edit(noun)">
<Icon v="pen" />
<span class="btn-label">
<T v-if="$isGranted('nouns')">crud.edit</T>
<T v-else>nouns.edit</T>
</span>
</button>
</li>
<li>
<a
:href="`/api/nouns/${noun.id}.png`"
target="_blank"
rel="noopener"
class="btn btn-concise btn-outline-primary btn-sm m-1"
>
<Icon v="image" />
<span class="btn-label">
<T>nouns.image</T>
</span>
</a>
</li>
</ul>
</template>
<template #empty>
<Icon v="search" />
<T>nouns.empty</T>
</template>
</Table>
</NounsTable>
<AdPlaceholder :phkey="['content-1', 'content-mobile-1']" />
@ -201,53 +185,3 @@ defineExpose({ loadNouns });
</template>
</Loading>
</template>
<style scoped lang="scss">
@import "assets/variables";
:deep(.row-header) {
grid-template-columns: 1fr 1fr 1fr 3em;
}
:deep(.row-content) {
grid:
"mascLabel masc mascPl"
"femLabel fem femPl"
"neutrLabel neutr neutrPl"
"sources sources sources"
"buttons buttons buttons"
/ auto 1fr 1fr;
@include media-breakpoint-up('md', $grid-breakpoints) {
grid:
"masc fem neutr buttons"
"mascPl femPl neutrPl buttons"
"sources sources sources buttons"
/ 1fr 1fr 1fr 3em;
}
}
.btn-concise {
white-space: nowrap;
}
@include media-breakpoint-up('md', $grid-breakpoints) {
.list-btn-concise {
min-width: 3rem;
li {
height: 2.5rem;
}
}
.btn-concise {
position: absolute;
.btn-label {
display: none;
}
&:hover .btn-label {
display: inline;
}
}
}
</style>

View File

@ -1,26 +1,16 @@
<script setup lang="ts">
import type { genders } from '~/src/classes.ts';
import { type Gender, iconNamesByGender, longIdentifierByGender } from '~/src/nouns.ts';
const props = defineProps<{
gender: typeof genders[number];
gender: Gender;
concise?: boolean;
}>();
const iconName = computed((): string => {
const iconNames = {
masc: 'mars',
fem: 'venus',
neutr: 'neuter',
};
return iconNames[props.gender];
return iconNamesByGender[props.gender];
});
const longIdentifier = computed((): string => {
const longIdentifiers = {
masc: 'masculine',
fem: 'feminine',
neutr: 'neuter',
};
return longIdentifiers[props.gender];
return longIdentifierByGender[props.gender];
});
</script>

View File

@ -1,9 +1,10 @@
<script setup lang="ts">
import type { genders, MinimalNoun } from '~/src/classes.ts';
import type { MinimalNoun } from '~/src/classes.ts';
import type { Gender } from '~/src/nouns.ts';
defineProps<{
noun: MinimalNoun;
gender: typeof genders[number];
gender: Gender;
plural?: boolean;
}>();

View File

@ -1,17 +1,20 @@
<script setup lang="ts">
import type { Config } from '~/locale/config.ts';
import { genders } from '~/src/classes.ts';
import type { Noun, MinimalNoun } from '~/src/classes.ts';
import { abbreviations } from '~/src/data.ts';
import { availableGenders } from '~/src/nouns.ts';
import type { Gender } from '~/src/nouns.ts';
const emptyForm = (config: Config): MinimalNoun => {
return {
masc: [''],
fem: [''],
neutr: [''],
nb: [''],
mascPl: config.nouns.pluralsRequired ? [''] : [],
femPl: config.nouns.pluralsRequired ? [''] : [],
neutrPl: config.nouns.pluralsRequired ? [''] : [],
nbPl: config.nouns.pluralsRequired ? [''] : [],
categories: [],
sources: [],
base: null,
@ -37,8 +40,8 @@ const templateBase = ref('');
const templateFilter = ref('');
const templateVisible = ref(false);
const canRemoveWord = (gender: typeof genders[number], plural: boolean): boolean => {
return genders.filter((otherGender) => {
const canRemoveWord = (gender: Gender, plural: boolean): boolean => {
return availableGenders(config).filter((otherGender) => {
return otherGender !== gender && form.value[plural ? `${otherGender}Pl` as const : otherGender].length > 0;
}).length > 1 || form.value[plural ? `${gender}Pl` as const : gender].length > 1;
};
@ -73,9 +76,11 @@ const edit = (word: Noun): void => {
masc: word.masc,
fem: word.fem,
neutr: word.neutr,
nb: word.nb,
mascPl: word.mascPl,
femPl: word.femPl,
neutrPl: word.neutrPl,
nbPl: word.nbPl,
categories: word.categories,
sources: word.sources,
base: word.id,
@ -109,10 +114,10 @@ const { data: sourcesKeys } = await useFetch('/api/sources/keys', { lazy: true,
</div>
<form v-else @submit.prevent="submit">
<div class="row">
<div v-if="config.nouns.plurals" class="col-12 col-md text-nowrap mt-md-4">
<div v-if="config.nouns.plurals" class="col-12 col-md-2 text-nowrap mt-md-4">
<label><strong> <T>nouns.singular</T></strong></label>
</div>
<div v-for="gender in genders" :key="gender" class="col-6 col-sm">
<div v-for="gender in availableGenders(config)" :key="gender" class="col-6 col-sm">
<label><strong><NounsGenderLabel :gender="gender" /></strong></label>
<ListInput
v-model="form[gender]"
@ -122,10 +127,10 @@ const { data: sourcesKeys } = await useFetch('/api/sources/keys', { lazy: true,
</div>
</div>
<div v-if="config.nouns.plurals" class="row">
<div class="col-12 col-md text-nowrap">
<div class="col-12 col-md-2 text-nowrap">
<label><strong> <T>nouns.plural</T></strong></label>
</div>
<div v-for="gender in genders" :key="gender" class="col-6 col-sm">
<div v-for="gender in availableGenders(config)" :key="gender" class="col-6 col-sm">
<label class="d-md-none"><strong><NounsGenderLabel :gender="gender" /></strong></label>
<ListInput
v-model="form[`${gender}Pl`]"

View File

@ -0,0 +1,84 @@
<script setup lang="ts" generic="T extends Partial<Noun> & MinimalNoun & { id: string }">
import type { MinimalNoun, Noun } from '~/src/classes.ts';
import { availableGenders } from '~/src/nouns.ts';
defineProps<{
nouns: T[];
marked?: (element: T) => boolean;
}>();
const config = useConfig();
const table = useTemplateRef('table');
defineExpose({
reset() {
table.value?.reset();
},
focus() {
table.value?.focus();
},
});
</script>
<template>
<Table ref="table" :data="nouns" :marked fixed>
<template #header>
<div v-for="gender in availableGenders(config)" :key="gender" class="d-none d-md-block bold">
<NounsGenderLabel :gender="gender" />
</div>
</template>
<template #row="{ el: noun }">
<NounsTableEntry :noun>
<template #buttons>
<ul class="list-unstyled list-btn-concise">
<slot name="buttons" :noun="noun"></slot>
</ul>
</template>
</NounsTableEntry>
</template>
<template #empty>
<Icon v="search" />
<T>nouns.empty</T>
</template>
</Table>
</template>
<style scoped lang="scss">
@import "assets/variables";
:deep(.row-header) {
grid-template-columns: 1fr 1fr 1fr 3em;
.nouns-table-nonbinary & {
grid-template-columns: 1fr 1fr 1fr 1fr 3em;
}
}
:deep(.row-content) {
grid:
"mascLabel masc mascPl"
"femLabel fem femPl"
"neutrLabel neutr neutrPl"
"nbLabel nb nbPl"
"sources sources sources"
"buttons buttons buttons"
/ auto 1fr 1fr;
@include media-breakpoint-up('md', $grid-breakpoints) {
grid:
"masc fem neutr buttons"
"mascPl femPl neutrPl buttons"
"sources sources sources buttons"
/ 1fr 1fr 1fr 3em;
.nouns-table-nonbinary & {
grid:
"masc fem neutr nb buttons"
"mascPl femPl neutrPl nbPl buttons"
"sources sources sources sources buttons"
/ 1fr 1fr 1fr 1fr 3em;
}
}
}
</style>

View File

@ -1,8 +1,9 @@
<script setup lang="ts">
import { genders, type Noun } from '~/src/classes.ts';
import type { MinimalNoun, Noun } from '~/src/classes.ts';
import { genders } from '~/src/nouns.ts';
const props = defineProps<{
noun: Noun;
noun: Partial<Noun> & MinimalNoun;
base?: Noun;
}>();
@ -43,7 +44,7 @@ const numerus = computed(() => {
</small>
</div>
</template>
<div v-if="noun.sourcesData.length" style="grid-area: sources">
<div v-if="noun.sourcesData?.length" style="grid-area: sources">
<p><strong><T>sources.referenced</T><T>quotation.colon</T></strong></p>
<ul class="list-unstyled mb-0">
<li v-for="source in noun.sourcesData" :key="source.id">

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { genders, gendersWithNumerus } from '~/src/classes.ts';
import type { MinimalNoun } from '~/src/classes.ts';
import { nounTemplates } from '~/src/data.ts';
import { gendersWithNumerus } from '~/src/nouns.ts';
const props = withDefaults(defineProps<{
templateBase?: string;
@ -30,27 +30,11 @@ const templates = computed((): (MinimalNoun & { id: string })[] => {
</script>
<template>
<Table :data="templates" :columns="3" fixed>
<template #header>
<th v-for="gender in genders" :key="gender" class="text-nowrap">
<NounsGenderLabel :gender="gender" />
</th>
<th></th>
<NounsTable :nouns="templates">
<template #buttons="{ noun }">
<ul class="list-unstyled list-btn-concise">
<slot name="buttons" :template="noun"></slot>
</ul>
</template>
<template #row="{ el: template }">
<td v-for="gender in genders" :key="gender">
<NounsItem :noun="template" :gender="gender" />
</td>
<th>
<ul class="list-unstyled list-btn-concise">
<slot name="buttons" :template="template"></slot>
</ul>
</th>
</template>
<template #empty>
<Icon v="search" />
<T>nouns.empty</T>
</template>
</Table>
</NounsTable>
</template>

View File

@ -213,6 +213,7 @@ nouns:
feminineShort: 'fem.'
neuter: 'neutral'
neuterShort: 'neutr.'
nonbinary: 'nonbinary'
singular: 'singular'
singularShort: 'sing.'

View File

@ -365,6 +365,10 @@ interface NounsConfig {
* whether the dictionary should be collapsed by default
*/
collapsable: boolean;
/**
* whether to have a nonbinary column
*/
nonbinary?: boolean;
/**
* whether nouns have plural forms
*/

View File

@ -1,4 +1,4 @@
import type { gendersWithNumerus } from '~/src/classes.ts';
import type { gendersWithNumerus } from '~/src/nouns.ts';
export type PronounData<M extends string> = {
key: string;

View File

@ -224,6 +224,7 @@ nouns:
feminineShort: 'fem.'
neuter: 'neutral'
neuterShort: 'neutr.'
nonbinary: 'nonbinary'
singular: 'singular'
singularShort: 'sing.'

View File

@ -100,6 +100,7 @@ nouns:
enabled: true
route: 'substantivoj'
collapsable: false
nonbinary: true
plurals: false
pluralsRequired: false
categories: []

View File

@ -6,8 +6,9 @@ import templates from './dukatywy.tsv';
import useConfig from '~/composables/useConfig.ts';
import type { NounTemplatesData } from '~/locale/data.ts';
import { gendersWithNumerus, Noun, NounDeclension, SourceLibrary } from '~/src/classes.ts';
import { Noun, NounDeclension, SourceLibrary } from '~/src/classes.ts';
import type { NounRaw, Source } from '~/src/classes.ts';
import { gendersWithNumerus } from '~/src/nouns.ts';
const dukajDeclension = new NounDeclension({
M: 'u',

View File

@ -6,8 +6,9 @@ import templates from './iksatywy.tsv';
import useConfig from '~/composables/useConfig.ts';
import type { NounTemplatesData } from '~/locale/data.ts';
import { gendersWithNumerus, Noun, NounDeclension, SourceLibrary } from '~/src/classes.ts';
import { Noun, NounDeclension, SourceLibrary } from '~/src/classes.ts';
import type { NounRaw, Source } from '~/src/classes.ts';
import { gendersWithNumerus } from '~/src/nouns.ts';
const xDeclension = new NounDeclension({
M: 'x',

View File

@ -0,0 +1,8 @@
-- Up
ALTER TABLE nouns
ADD COLUMN nb TEXT AFTER neutr NOT NULL DEFAULT '';
ALTER TABLE nouns
ADD COLUMN nbPl TEXT AFTER neutrPl NOT NULL DEFAULT '';
-- Down

View File

@ -13,6 +13,8 @@ definePageMeta({
},
});
const NounsNav = useLocaleComponent('nouns', 'NounsNav');
const { $translator: translator } = useNuxtApp();
useSimpleHead({
title: translator.translate('nouns.headerLonger'),

View File

@ -16,9 +16,10 @@ import { rootDir } from '~/server/paths.ts';
import { parsePronounGroups, parsePronouns, shortForVariant } from '~/src/buildPronoun.ts';
import { buildCalendar } from '~/src/calendar/calendar.ts';
import { Day } from '~/src/calendar/helpers.ts';
import { genders, gendersWithNumerus, PronounLibrary } from '~/src/classes.ts';
import { PronounLibrary } from '~/src/classes.ts';
import forbidden from '~/src/forbidden.ts';
import { clearLinkedText, buildImageUrl } from '~/src/helpers.ts';
import { genders, gendersWithNumerus } from '~/src/nouns.ts';
import parseMarkdown from '~/src/parseMarkdown.ts';
import { normaliseQuery, type SearchDocument, validateQuery } from '~/src/search.ts';
import { Translator } from '~/src/translator.ts';

View File

@ -15,6 +15,8 @@ import { registerLocaleFont } from '../localeFont.ts';
import type { SourceRow } from './sources.ts';
import type { UserRow } from './user.ts';
import { availableGenders, iconUnicodesByGender, longIdentifierByGender } from '~/src/nouns.ts';
const translations = loadSuml('translations') as Translations;
interface NounRow {
@ -22,9 +24,11 @@ interface NounRow {
masc: string;
fem: string;
neutr: string;
nb: string;
mascPl: string;
femPl: string;
neutrPl: string;
nbPl: string;
approved: number;
base_id: string | null;
locale: string;
@ -128,7 +132,7 @@ router.get('/nouns/search/:term', handleErrorAsync(async (req, res) => {
WHERE n.locale = ${global.config.locale}
AND n.approved >= ${req.isGranted('nouns') ? 0 : 1}
AND n.deleted = 0
AND (n.masc like ${term} OR n.fem like ${term} OR n.neutr like ${term} OR n.mascPl like ${term} OR n.femPl like ${term} OR n.neutrPl like ${term})
AND (n.masc like ${term} OR n.fem like ${term} OR n.neutr like ${term} OR n.nb like ${term} OR n.mascPl like ${term} OR n.femPl like ${term} OR n.neutrPl like ${term} OR n.nbPl like ${term})
ORDER BY n.approved, n.masc
`)));
}));
@ -140,11 +144,11 @@ router.post('/nouns/submit', handleErrorAsync(async (req, res) => {
const id = ulid();
await req.db.get(SQL`
INSERT INTO nouns (id, masc, fem, neutr, mascPl, femPl, neutrPl, categories, sources, approved, base_id, locale, author_id)
INSERT INTO nouns (id, masc, fem, neutr, nb, mascPl, femPl, neutrPl, nbPl, categories, sources, approved, base_id, locale, author_id)
VALUES (
${id},
${req.body.masc.join('|')}, ${req.body.fem.join('|')}, ${req.body.neutr.join('|')},
${req.body.mascPl.join('|')}, ${req.body.femPl.join('|')}, ${req.body.neutrPl.join('|')},
${req.body.masc.join('|')}, ${req.body.fem.join('|')}, ${req.body.neutr.join('|')}, ${req.body.nb.join('|')},
${req.body.mascPl.join('|')}, ${req.body.femPl.join('|')}, ${req.body.neutrPl.join('|')}, ${req.body.nbPl.join('|')},
${req.body.categories.join('|')},
${req.body.sources ? req.body.sources.join(',') : null},
0, ${req.body.base}, ${global.config.locale}, ${req.user ? req.user.id : null}
@ -223,8 +227,10 @@ router.get('/nouns/:id.png', async (req, res) => {
return res.status(404).json({ error: 'Not found' });
}
const genders = availableGenders(global.config);
let maxItems = 0;
(['masc', 'fem', 'neutr'] as const).forEach((form) => {
genders.forEach((form) => {
let items = 0;
for (const key of ['', 'Pl'] as const) {
items += noun[`${form}${key}`].split('|').filter((x) => x.length).length;
@ -235,7 +241,7 @@ router.get('/nouns/:id.png', async (req, res) => {
});
const padding = 48;
const width = 1200;
const width = genders.length * 400;
const height = padding * 2.5 + (maxItems + 1) * 48 + padding;
const mime = 'image/png';
@ -250,21 +256,22 @@ router.get('/nouns/:id.png', async (req, res) => {
context.font = `bold 64pt '${fontName}'`;
for (const [column, key, icon] of [[0, 'masculine', '\uf222'], [1, 'feminine', '\uf221'], [2, 'neuter', '\uf22c']] as const) {
genders.forEach((gender, column) => {
context.font = 'regular 24pt FontAwesome';
context.fillText(icon, column * (width - 2 * padding) / 3 + padding, padding * 1.5);
context.fillText(iconUnicodesByGender[gender], column * (width - 2 * padding) / genders.length + padding, padding * 1.5);
context.font = `bold 24pt '${fontName}'`;
context.fillText(translations.nouns[key], column * (width - 2 * padding) / 3 + padding + 36, padding * 1.5);
}
const header = translations.nouns[longIdentifierByGender[gender]];
context.fillText(header, column * (width - 2 * padding) / genders.length + padding + 36, padding * 1.5);
});
context.font = `regular 24pt '${fontName}'`;
(['masc', 'fem', 'neutr'] as const).forEach((form, column) => {
genders.forEach((form, column) => {
let i = 0;
for (const [key, symbol] of [['', '⋅'], ['Pl', '⁖']] as const) {
noun[`${form}${key}`].split('|').filter((x) => x.length)
.forEach((part) => {
context.fillText(`${symbol} ${part}`, column * (width - 2 * padding) / 3 + padding, padding * 2.5 + i * 48);
context.fillText(`${symbol} ${part}`, column * (width - 2 * padding) / genders.length + padding, padding * 2.5 + i * 48);
i++;
});
}

View File

@ -5,6 +5,7 @@ import { buildDict, buildList, capitalise, escapeControlSymbols, escapePronuncia
import type { Translator } from './translator.ts';
import type { NounTemplatesData } from '~/locale/data.ts';
import { gendersWithNumerus } from '~/src/nouns.ts';
export class ExamplePart {
variable: boolean;
@ -935,9 +936,11 @@ export interface NounRaw {
masc: string;
fem: string;
neutr: string;
nb?: string;
mascPl: string;
femPl: string;
neutrPl: string;
nbPl?: string;
categories?: string | null;
sources?: string | null;
sourcesData?: SourceRaw[];
@ -947,17 +950,16 @@ export interface NounRaw {
declension?: NounDeclension | null;
}
export const genders = ['masc', 'fem', 'neutr'] as const;
export const gendersWithNumerus = ['masc', 'fem', 'neutr', 'mascPl', 'femPl', 'neutrPl'] as const;
export class Noun implements Entry {
id: string;
masc: string[];
fem: string[];
neutr: string[];
nb: string[];
mascPl: string[];
femPl: string[];
neutrPl: string[];
nbPl: string[];
categories: string[];
sources: string[];
sourcesData: Source[];
@ -967,16 +969,19 @@ export class Noun implements Entry {
declension: NounDeclension | null;
constructor(config: Config, {
id, masc, fem, neutr, mascPl, femPl, neutrPl, categories = null, sources = null, sourcesData = [],
id, masc, fem, neutr, nb = '', mascPl, femPl, neutrPl, nbPl = '',
categories = null, sources = null, sourcesData = [],
approved = true, base_id = null, author = null, declension = null,
}: NounRaw) {
this.id = id;
this.masc = masc ? masc.split('|') : [];
this.fem = fem ? fem.split('|') : [];
this.neutr = neutr ? neutr.split('|') : [];
this.nb = nb ? nb.split('|') : [];
this.mascPl = mascPl ? mascPl.split('|') : [];
this.femPl = femPl ? femPl.split('|') : [];
this.neutrPl = neutrPl ? neutrPl.split('|') : [];
this.nbPl = nbPl ? nbPl.split('|') : [];
this.categories = categories?.split('|') ?? [];
this.sources = sources ? sources.split(',') : [];
this.sourcesData = sourcesData.filter((s) => !!s).map((s) => new Source(config, s));
@ -1032,17 +1037,21 @@ export class NounTemplate {
masc: string[];
fem: string[];
neutr: string[];
nb: string[];
mascPl: string[];
femPl: string[];
neutrPl: string[];
nbPl: string[];
constructor(masc: string[], fem: string[], neutr: string[], mascPl: string[], femPl: string[], neutrPl: string[]) {
constructor(masc: string[], fem: string[], neutr: string[], nb: string[], mascPl: string[], femPl: string[], neutrPl: string[], nbPl: string[]) {
this.masc = masc;
this.fem = fem;
this.neutr = neutr;
this.nb = nb;
this.mascPl = mascPl;
this.femPl = femPl;
this.neutrPl = neutrPl;
this.nbPl = nbPl;
}
static from(data: NounTemplatesData): NounTemplate {
@ -1050,9 +1059,11 @@ export class NounTemplate {
data.masc?.split('/') ?? [],
data.fem?.split('/') ?? [],
data.neutr?.split('/') ?? [],
data.nb?.split('/') ?? [],
data.mascPl?.split('/') ?? [],
data.femPl?.split('/') ?? [],
data.neutrPl?.split('/') ?? [],
data.nbPl?.split('/') ?? [],
);
}
@ -1061,9 +1072,11 @@ export class NounTemplate {
masc: this.masc.map((e) => e.replace('-', stem)),
fem: this.fem.map((e) => e.replace('-', stem)),
neutr: this.neutr.map((e) => e.replace('-', stem)),
nb: this.nb.map((e) => e.replace('-', stem)),
mascPl: this.mascPl.map((e) => e.replace('-', stem)),
femPl: this.femPl.map((e) => e.replace('-', stem)),
neutrPl: this.neutrPl.map((e) => e.replace('-', stem)),
nbPl: this.nbPl.map((e) => e.replace('-', stem)),
categories: [],
sources: [],
base: null,

View File

@ -146,6 +146,10 @@ export function listMissingTranslations(
return false;
}
if (!config.nouns.nonbinary && keyMatches('nouns.nonbinary')) {
return false;
}
if (config.nouns.categories?.length === 0 && keyMatches('nouns.categories')) {
return false;
}

29
src/nouns.ts Normal file
View File

@ -0,0 +1,29 @@
import type { Config } from '~/locale/config.ts';
export const genders = ['masc', 'fem', 'neutr', 'nb'] as const;
export type Gender = typeof genders[number];
export const gendersWithNumerus = ['masc', 'fem', 'neutr', 'nb', 'mascPl', 'femPl', 'neutrPl', 'nbPl'] as const;
export const availableGenders = (config: Config): Gender[] => {
return genders.filter((gender) => gender !== 'nb' || config.nouns.nonbinary);
};
export const iconNamesByGender: Record<Gender, string> = {
masc: 'mars',
fem: 'venus',
neutr: 'neuter',
nb: 'transgender-alt',
};
export const iconUnicodesByGender: Record<Gender, string> = {
masc: '\uf222',
fem: '\uf221',
neutr: '\uf22c',
nb: '\uf225',
};
export const longIdentifierByGender: Record<Gender, string> = {
masc: 'masculine',
fem: 'feminine',
neutr: 'neuter',
nb: 'nonbinary',
};

View File

@ -5,10 +5,11 @@ import type { Config } from '../../locale/config.ts';
import type { NounTemplatesData, PronounGroupData, PronounExamplesData, PronounData } from '../../locale/data.ts';
import allLocales from '../../locale/locales.ts';
import { loadSumlFromBase } from '../../server/loader.ts';
import { Example, gendersWithNumerus } from '../../src/classes.ts';
import { Example } from '../../src/classes.ts';
import { loadTsv } from '../../src/tsv.ts';
import { normaliseKey } from '~/src/buildPronoun.ts';
import { gendersWithNumerus } from '~/src/nouns.ts';
const __dirname = new URL('.', import.meta.url).pathname;
@ -152,7 +153,10 @@ describe.each(allLocales)('data files of $code', ({ code }) => {
test('nouns/nounTemplates.tsv have exactly one hyphen as placeholder for root', () => {
for (const template of nounTemplates) {
for (const genderWithNumerus of gendersWithNumerus) {
expect(template[genderWithNumerus]).toMatch(/^(?:[^-]*-[^-]*(?:\/|$))+/);
const actual = template[genderWithNumerus];
if (typeof actual === 'string') {
expect(actual).toMatch(/^(?:[^-]*-[^-]*(?:\/|$))+/);
}
}
}
});