responsive interface
This commit is contained in:
+76
-37
@@ -25,9 +25,20 @@ const lastSyncAt = ref<string | null>(null)
|
||||
const isOnline = ref(navigator.onLine)
|
||||
const isSyncing = ref(false)
|
||||
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 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') {
|
||||
return others
|
||||
}
|
||||
@@ -127,6 +138,20 @@ const setLanguageFilter = (code: Language['code'] | 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 () => {
|
||||
if (!db.value) {
|
||||
return
|
||||
@@ -160,6 +185,13 @@ onMounted(async () => {
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
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) {
|
||||
await syncNow()
|
||||
@@ -169,6 +201,9 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener('change', mediaHandler)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -238,30 +273,6 @@ onBeforeUnmount(() => {
|
||||
{{ category }}
|
||||
</button>
|
||||
</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 v-if="languageFilter" class="alphabet-row">
|
||||
@@ -286,14 +297,48 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section class="stats-row">
|
||||
<div>
|
||||
<h2 class="section-title">Translation Table</h2>
|
||||
<p class="section-subtitle">
|
||||
{{ filteredEntries.length }} of {{ entries.length }} entries
|
||||
</p>
|
||||
<div class="section-heading">
|
||||
<h2 class="section-title">Translation Table</h2>
|
||||
<span class="section-subtitle">
|
||||
{{ filteredEntries.length }} of {{ entries.length }} entries
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="badge">Missing: {{ filteredMissingCount }}</span>
|
||||
<span class="badge success">Complete: {{ filteredEntries.length - filteredMissingCount }}</span>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -331,12 +376,6 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<td class="sticky-col">
|
||||
<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 v-for="language in displayLanguages" :key="language.code">
|
||||
<span v-if="entry[language.code]">{{ entry[language.code] }}</span>
|
||||
|
||||
+92
-30
@@ -32,6 +32,7 @@ body {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 10% 20%, #eef2ff 0%, #f8fafc 45%, #f1f5f9 100%);
|
||||
color: var(--slate-900);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
@@ -147,6 +148,7 @@ body {
|
||||
.content {
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 40px;
|
||||
display: flex;
|
||||
@@ -154,10 +156,27 @@ body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
@@ -181,6 +200,7 @@ body {
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 18px 44px 18px 50px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--slate-200);
|
||||
@@ -212,6 +232,7 @@ body {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
@@ -316,6 +337,49 @@ body {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
@@ -323,8 +387,14 @@ body {
|
||||
color: var(--slate-900);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 4px 0 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--slate-600);
|
||||
}
|
||||
@@ -356,11 +426,13 @@ body {
|
||||
border: 1px solid var(--slate-200);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
max-height: 68vh;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.glossary {
|
||||
@@ -438,35 +510,6 @@ body {
|
||||
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 {
|
||||
padding: 24px;
|
||||
color: var(--slate-600);
|
||||
@@ -494,6 +537,25 @@ body {
|
||||
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 {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user