responsive interface
This commit is contained in:
+74
-35
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user