responsive interface

This commit is contained in:
2026-02-06 01:29:59 +01:00
parent 43463bbe8f
commit 54a426b485
2 changed files with 168 additions and 67 deletions
+74 -35
View File
@@ -25,9 +25,20 @@ const lastSyncAt = ref<string | null>(null)
const isOnline = ref(navigator.onLine) const isOnline = ref(navigator.onLine)
const isSyncing = ref(false) const isSyncing = ref(false)
const syncError = ref<string | null>(null) const syncError = ref<string | null>(null)
const isMobile = ref(false)
const secondaryLanguage = ref<Language['code']>('en')
let mediaQuery: MediaQueryList | null = null
let mediaHandler: ((event: MediaQueryListEvent) => void) | null = null
const displayLanguages = computed(() => { const displayLanguages = computed(() => {
const others = languages.filter((lang) => lang.code !== preferredLanguage.value) const others = languages.filter((lang) => lang.code !== preferredLanguage.value)
if (isMobile.value) {
const secondary = secondaryLanguage.value
if (secondary === preferredLanguage.value) {
return others.slice(0, 1)
}
return others.filter((lang) => lang.code === secondary).slice(0, 1)
}
if (preferredLanguage.value === 'en') { if (preferredLanguage.value === 'en') {
return others return others
} }
@@ -127,6 +138,20 @@ const setLanguageFilter = (code: Language['code'] | null) => {
startsWith.value = null startsWith.value = null
} }
const ensureSecondaryLanguage = () => {
if (secondaryLanguage.value === preferredLanguage.value) {
const fallback = languages.find((lang) => lang.code !== preferredLanguage.value)
secondaryLanguage.value = fallback ? fallback.code : preferredLanguage.value
}
}
const swapLanguages = () => {
const current = preferredLanguage.value
preferredLanguage.value = secondaryLanguage.value
secondaryLanguage.value = current
ensureSecondaryLanguage()
}
const syncNow = async () => { const syncNow = async () => {
if (!db.value) { if (!db.value) {
return return
@@ -160,6 +185,13 @@ onMounted(async () => {
window.addEventListener('online', handleOnline) window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline) window.addEventListener('offline', handleOffline)
mediaQuery = window.matchMedia('(max-width: 900px)')
isMobile.value = mediaQuery.matches
mediaHandler = (event: MediaQueryListEvent) => {
isMobile.value = event.matches
}
mediaQuery.addEventListener('change', mediaHandler)
ensureSecondaryLanguage()
if (isOnline.value) { if (isOnline.value) {
await syncNow() await syncNow()
@@ -169,6 +201,9 @@ onMounted(async () => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('online', handleOnline) window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline) window.removeEventListener('offline', handleOffline)
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener('change', mediaHandler)
}
}) })
</script> </script>
@@ -238,30 +273,6 @@ onBeforeUnmount(() => {
{{ category }} {{ category }}
</button> </button>
</div> </div>
<div class="filter-group">
<span class="filter-label">Language</span>
<button class="chip dark" :class="{ active: !languageFilter }" @click="setLanguageFilter(null)">
All
</button>
<button
v-for="language in languages"
:key="language.code"
class="chip dark"
:class="{ active: languageFilter === language.code }"
@click="setLanguageFilter(language.code)"
>
{{ language.name }}
</button>
</div>
<div class="filter-group">
<span class="filter-label">Primary column</span>
<select v-model="preferredLanguage" class="select">
<option v-for="language in languages" :key="language.code" :value="language.code">
{{ language.name }}
</option>
</select>
</div>
<button class="btn btn-ghost" @click="clearFilters">Reset filters</button>
</div> </div>
<div v-if="languageFilter" class="alphabet-row"> <div v-if="languageFilter" class="alphabet-row">
@@ -286,14 +297,48 @@ onBeforeUnmount(() => {
<section class="stats-row"> <section class="stats-row">
<div> <div>
<div class="section-heading">
<h2 class="section-title">Translation Table</h2> <h2 class="section-title">Translation Table</h2>
<p class="section-subtitle"> <span class="section-subtitle">
{{ filteredEntries.length }} of {{ entries.length }} entries {{ filteredEntries.length }} of {{ entries.length }} entries
</p> </span>
</div>
</div>
<div class="language-controls" v-if="isMobile">
<div class="language-control">
<select
v-model="preferredLanguage"
class="select"
aria-label="Primary language"
@change="ensureSecondaryLanguage"
>
<option v-for="language in languages" :key="language.code" :value="language.code">
{{ language.name }}
</option>
</select>
</div>
<a class="swap-link" href="#" aria-label="Swap languages" @click.prevent="swapLanguages">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M7 7h7m0 0l-2-2m2 2l-2 2M17 17h-7m0 0l2-2m-2 2l2 2M5 12a7 7 0 0112.03-4.95M19 12a7 7 0 01-12.03 4.95" />
</svg>
</a>
<div class="language-control secondary">
<select
v-model="secondaryLanguage"
class="select"
aria-label="Secondary language"
@change="ensureSecondaryLanguage"
>
<option
v-for="language in languages"
:key="language.code"
:value="language.code"
:disabled="language.code === preferredLanguage"
>
{{ language.name }}
</option>
</select>
</div> </div>
<div class="stats">
<span class="badge">Missing: {{ filteredMissingCount }}</span>
<span class="badge success">Complete: {{ filteredEntries.length - filteredMissingCount }}</span>
</div> </div>
</section> </section>
@@ -331,12 +376,6 @@ onBeforeUnmount(() => {
> >
<td class="sticky-col"> <td class="sticky-col">
<div class="entry-title">{{ entry[preferredLanguage] || 'Untitled' }}</div> <div class="entry-title">{{ entry[preferredLanguage] || 'Untitled' }}</div>
<div class="entry-meta">
<span class="meta-tag">{{ entry.category || 'General' }}</span>
<span class="meta-pill" :class="entry[preferredLanguage] ? 'verified' : 'unverified'">
{{ entry[preferredLanguage] ? 'Verified' : 'Unverified' }}
</span>
</div>
</td> </td>
<td v-for="language in displayLanguages" :key="language.code"> <td v-for="language in displayLanguages" :key="language.code">
<span v-if="entry[language.code]">{{ entry[language.code] }}</span> <span v-if="entry[language.code]">{{ entry[language.code] }}</span>
+92 -30
View File
@@ -32,6 +32,7 @@ body {
min-height: 100vh; min-height: 100vh;
background: radial-gradient(circle at 10% 20%, #eef2ff 0%, #f8fafc 45%, #f1f5f9 100%); background: radial-gradient(circle at 10% 20%, #eef2ff 0%, #f8fafc 45%, #f1f5f9 100%);
color: var(--slate-900); color: var(--slate-900);
overflow-x: hidden;
} }
#app { #app {
@@ -147,6 +148,7 @@ body {
.content { .content {
flex: 1; flex: 1;
max-width: 1200px; max-width: 1200px;
width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 24px 20px 40px; padding: 24px 20px 40px;
display: flex; display: flex;
@@ -154,10 +156,27 @@ body {
gap: 24px; gap: 24px;
} }
@media (orientation: landscape) and (max-width: 1023px) {
.header-inner,
.header-sub,
.content {
max-width: 500px;
}
}
@media (min-width: 1024px) {
.header-inner,
.header-sub,
.content {
max-width: 1200px;
}
}
.search-area { .search-area {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
width: 100%;
} }
.search-field { .search-field {
@@ -181,6 +200,7 @@ body {
.search-input { .search-input {
width: 100%; width: 100%;
min-width: 0;
padding: 18px 44px 18px 50px; padding: 18px 44px 18px 50px;
border-radius: 18px; border-radius: 18px;
border: 1px solid var(--slate-200); border: 1px solid var(--slate-200);
@@ -212,6 +232,7 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px 20px; gap: 12px 20px;
align-items: center; align-items: center;
width: 100%;
} }
.filter-group { .filter-group {
@@ -316,6 +337,49 @@ body {
gap: 12px; gap: 12px;
} }
.language-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.language-control {
display: flex;
align-items: center;
gap: 6px;
justify-content: flex-start;
}
.language-control.secondary {
margin-left: auto;
justify-content: flex-end;
}
.swap-link {
border: 1px solid var(--slate-200);
background: transparent;
color: var(--slate-600);
border-radius: 10px;
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
text-decoration: none;
}
.swap-link svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.section-title { .section-title {
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
@@ -323,8 +387,14 @@ body {
color: var(--slate-900); color: var(--slate-900);
} }
.section-heading {
display: flex;
align-items: baseline;
gap: 8px;
}
.section-subtitle { .section-subtitle {
margin: 4px 0 0; margin: 0;
font-size: 12px; font-size: 12px;
color: var(--slate-600); color: var(--slate-600);
} }
@@ -356,11 +426,13 @@ body {
border: 1px solid var(--slate-200); border: 1px solid var(--slate-200);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
overflow: hidden; overflow: hidden;
width: 100%;
} }
.table-scroll { .table-scroll {
max-height: 68vh; max-height: 68vh;
overflow: auto; overflow: auto;
max-width: 100%;
} }
.glossary { .glossary {
@@ -438,35 +510,6 @@ body {
color: var(--slate-900); color: var(--slate-900);
} }
.entry-meta {
margin-top: 6px;
display: flex;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: var(--slate-600);
}
.meta-tag {
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
}
.meta-pill {
padding: 2px 8px;
border-radius: 999px;
font-weight: 700;
}
.meta-pill.verified {
color: var(--emerald-600);
}
.meta-pill.unverified {
color: var(--amber-500);
}
.empty-cell { .empty-cell {
padding: 24px; padding: 24px;
color: var(--slate-600); color: var(--slate-600);
@@ -494,6 +537,25 @@ body {
align-items: flex-start; align-items: flex-start;
} }
.language-controls {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
gap: 8px;
}
.language-control {
width: 100%;
}
.language-control.secondary {
margin-left: 0;
}
.language-control .select {
width: 100%;
}
.table-scroll { .table-scroll {
max-height: none; max-height: none;
} }