Skip to content

Commit a10a1be

Browse files
André DietrichAndré Dietrich
authored andcommitted
improve: import modal
1 parent 1d6c719 commit a10a1be

31 files changed

Lines changed: 486 additions & 61 deletions

scripts/check-i18n.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env node
2+
const fs = require("fs");
3+
const path = require("path");
4+
5+
const I18N_DIR = path.resolve(__dirname, "..", "src", "i18n");
6+
const REFERENCE_FILE = "en.json";
7+
8+
function readJson(file) {
9+
return JSON.parse(fs.readFileSync(path.join(I18N_DIR, file), "utf8"));
10+
}
11+
12+
function flattenEntries(value, prefix = "") {
13+
if (value && typeof value === "object" && !Array.isArray(value)) {
14+
return Object.keys(value).flatMap((key) =>
15+
flattenEntries(value[key], prefix ? `${prefix}.${key}` : key)
16+
);
17+
}
18+
19+
return [[prefix, value]];
20+
}
21+
22+
function placeholderNames(value) {
23+
if (typeof value !== "string") return [];
24+
return [...value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]).sort();
25+
}
26+
27+
function diff(left, right) {
28+
return [...left].filter((key) => !right.has(key)).sort();
29+
}
30+
31+
const files = fs
32+
.readdirSync(I18N_DIR)
33+
.filter((file) => file.endsWith(".json"))
34+
.sort();
35+
36+
const maps = Object.fromEntries(
37+
files.map((file) => [file, new Map(flattenEntries(readJson(file)))])
38+
);
39+
40+
const reference = maps[REFERENCE_FILE];
41+
const referenceKeys = new Set(reference.keys());
42+
const issues = [];
43+
44+
for (const file of files) {
45+
const map = maps[file];
46+
const keys = new Set(map.keys());
47+
const missing = diff(referenceKeys, keys);
48+
const extra = diff(keys, referenceKeys);
49+
50+
if (missing.length) {
51+
issues.push(`${file}: missing keys:\n ${missing.join("\n ")}`);
52+
}
53+
54+
if (extra.length) {
55+
issues.push(`${file}: extra keys:\n ${extra.join("\n ")}`);
56+
}
57+
58+
for (const [key, value] of map) {
59+
if (typeof value !== "string" || value.length === 0) {
60+
issues.push(`${file}: ${key} must be a non-empty string`);
61+
}
62+
}
63+
}
64+
65+
for (const file of files.filter((file) => file !== REFERENCE_FILE)) {
66+
const map = maps[file];
67+
68+
for (const [key, referenceValue] of reference) {
69+
if (!map.has(key)) continue;
70+
71+
const expected = placeholderNames(referenceValue).join("|");
72+
const actual = placeholderNames(map.get(key)).join("|");
73+
74+
if (expected !== actual) {
75+
issues.push(
76+
`${file}: ${key} placeholders differ from ${REFERENCE_FILE} ` +
77+
`(expected: ${expected || "-"}, actual: ${actual || "-"})`
78+
);
79+
}
80+
}
81+
}
82+
83+
if (issues.length) {
84+
console.error(`i18n check failed with ${issues.length} issue(s):`);
85+
console.error(issues.join("\n\n"));
86+
process.exit(1);
87+
}
88+
89+
console.log(`i18n check passed for ${files.length} locale files.`);

src/components/ImportMenu.vue

Lines changed: 23 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,42 @@
11
<script lang="ts">
22
import { defineComponent } from "vue";
3-
import { importSources, ImportSource } from "../ts/importSources";
3+
import { ImportSource } from "../ts/importSources";
4+
import ImportModal from "./ImportModal.vue";
45
import ImportUrlModal from "./ImportUrlModal.vue";
56
6-
// Split button on the index page: the primary action creates a new empty
7-
// course, the dropdown lists every registered import source. New sources are
8-
// picked up automatically from the importSources registry.
7+
// "New" button on the index page. The button opens a card chooser (ImportModal)
8+
// that lists every registered import source — including the blank-document
9+
// option. Picking a card dispatches here: navigate runs immediately, fileInput
10+
// triggers the hidden <input>, and modal opens the shared text-input dialog.
911
export default defineComponent({
1012
name: "ImportMenu",
1113
12-
components: { ImportUrlModal },
14+
components: { ImportModal, ImportUrlModal },
1315
1416
data() {
1517
return {
16-
open: false,
17-
sources: importSources,
18-
primary: importSources.find((s) => s.id === "new") as ImportSource,
1918
acceptFor: "" as string,
2019
pendingOnFiles: null as ((files: FileList) => Promise<void>) | null,
2120
busy: false,
2221
};
2322
},
2423
25-
computed: {
26-
extraSources(): ImportSource[] {
27-
return this.sources.filter(
28-
(s) => s.id !== "new" && (s.available ? s.available() : true)
29-
);
24+
methods: {
25+
openChooser() {
26+
(this.$refs.chooser as any)?.open();
3027
},
31-
},
3228
33-
methods: {
3429
select(source: ImportSource) {
35-
this.open = false;
3630
if (source.kind === "navigate") {
3731
source.onSelect?.();
3832
} else if (source.kind === "fileInput") {
3933
this.acceptFor = source.accept || "";
4034
this.pendingOnFiles = source.onFiles || null;
4135
this.$nextTick(() => (this.$refs.file as HTMLInputElement)?.click());
4236
} else if (source.kind === "modal") {
43-
(this.$refs.modal as any)?.open(source);
37+
// The chooser modal is closing; wait a tick so its backdrop is gone
38+
// before the text-input modal opens.
39+
this.$nextTick(() => (this.$refs.modal as any)?.open(source));
4440
}
4541
},
4642
@@ -56,48 +52,22 @@ export default defineComponent({
5652
}
5753
input.value = "";
5854
},
59-
60-
onOutsideClick(e: MouseEvent) {
61-
if (!(this.$el as HTMLElement).contains(e.target as Node)) {
62-
this.open = false;
63-
}
64-
},
65-
},
66-
67-
mounted() {
68-
document.addEventListener("click", this.onOutsideClick);
69-
},
70-
71-
beforeUnmount() {
72-
document.removeEventListener("click", this.onOutsideClick);
7355
},
7456
});
7557
</script>
7658

7759
<template>
78-
<div class="btn-group" :class="{ show: open }">
79-
<button class="btn btn-primary" type="button" :disabled="busy" @click="select(primary)">
80-
<span v-if="busy" class="spinner-border spinner-border-sm me-1"></span>
81-
<i v-else class="bi" :class="primary.icon"></i>
82-
{{ $t(primary.labelKey) }}
83-
</button>
60+
<div>
8461
<button
85-
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
62+
class="btn btn-primary"
8663
type="button"
87-
:aria-expanded="open"
88-
:aria-label="$t('index.import.more')"
89-
@click.stop="open = !open"
90-
></button>
91-
<ul class="dropdown-menu dropdown-menu-end" :class="{ show: open }">
92-
<li>
93-
<h6 class="dropdown-header">{{ $t('index.import.heading') }}</h6>
94-
</li>
95-
<li v-for="source in extraSources" :key="source.id">
96-
<button class="dropdown-item" type="button" @click="select(source)">
97-
<i class="bi me-2" :class="source.icon"></i>{{ $t(source.labelKey) }}
98-
</button>
99-
</li>
100-
</ul>
64+
:disabled="busy"
65+
@click="openChooser"
66+
>
67+
<span v-if="busy" class="spinner-border spinner-border-sm me-1"></span>
68+
<i v-else class="bi bi-file-earmark-plus-fill"></i>
69+
{{ $t("index.import.new") }}
70+
</button>
10171

10272
<input
10373
ref="file"
@@ -107,15 +77,7 @@ export default defineComponent({
10777
@change="onFileChange"
10878
/>
10979

80+
<ImportModal ref="chooser" @select="select" />
11081
<ImportUrlModal ref="modal" />
11182
</div>
11283
</template>
113-
114-
<style scoped>
115-
/* We toggle the menu manually (no Bootstrap Popper), so dropdown-menu-end has
116-
no effect — force right alignment so the menu opens inside the viewport. */
117-
.dropdown-menu {
118-
right: 0;
119-
left: auto;
120-
}
121-
</style>

src/components/ImportModal.vue

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<script lang="ts">
2+
import { defineComponent } from "vue";
3+
import Modal from "bootstrap/js/dist/modal";
4+
import { importSources, ImportSource } from "../ts/importSources";
5+
6+
// Card chooser shown when the user clicks "New" on the index page. It renders one
7+
// card per registered import source (including the blank-document option) with a
8+
// large icon, a label and a plain-language description so non-technical users
9+
// understand what each option does. Picking a card hands the source back to the
10+
// parent (ImportMenu) whose select() dispatches navigate / fileInput / modal.
11+
export default defineComponent({
12+
name: "ImportModal",
13+
14+
emits: ["select"],
15+
16+
data() {
17+
return {
18+
sources: importSources,
19+
modal: null as Modal | null,
20+
};
21+
},
22+
23+
methods: {
24+
// Every source stays visible so users discover all options; the ones whose
25+
// available() gate fails (e.g. local folder outside Chromium) render disabled
26+
// with an explanatory note instead of being hidden.
27+
isDisabled(source: ImportSource): boolean {
28+
return source.available ? !source.available() : false;
29+
},
30+
31+
open() {
32+
if (!this.modal) {
33+
this.modal = new Modal(this.$refs.el as Element);
34+
}
35+
this.modal.show();
36+
},
37+
38+
pick(source: ImportSource) {
39+
if (this.isDisabled(source)) return;
40+
// Hide the chooser first; the parent opens the follow-up text-input modal
41+
// (GitHub / URL / Gist) on $nextTick to avoid stacked-backdrop glitches.
42+
this.modal?.hide();
43+
this.$emit("select", source);
44+
},
45+
},
46+
});
47+
</script>
48+
49+
<template>
50+
<div ref="el" class="modal fade" tabindex="-1" aria-hidden="true">
51+
<div class="modal-dialog modal-lg modal-dialog-centered">
52+
<div class="modal-content">
53+
<div class="modal-header">
54+
<h5 class="modal-title">{{ $t("index.import.title") }}</h5>
55+
<button
56+
type="button"
57+
class="btn-close"
58+
data-bs-dismiss="modal"
59+
:aria-label="$t('card.close')"
60+
></button>
61+
</div>
62+
<div class="modal-body">
63+
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
64+
<div v-for="source in sources" :key="source.id" class="col">
65+
<button
66+
type="button"
67+
class="import-card"
68+
:class="{ 'import-card--disabled': isDisabled(source) }"
69+
:disabled="isDisabled(source)"
70+
@click="pick(source)"
71+
>
72+
<i class="bi import-card-icon" :class="source.icon"></i>
73+
<span class="import-card-title">{{ $t(source.labelKey) }}</span>
74+
<small v-if="source.descriptionKey" class="import-card-desc text-muted">
75+
{{ $t(source.descriptionKey) }}
76+
</small>
77+
<span v-if="isDisabled(source)" class="badge text-bg-secondary import-card-badge">
78+
{{ $t("index.import.unavailable") }}
79+
</span>
80+
</button>
81+
</div>
82+
</div>
83+
</div>
84+
</div>
85+
</div>
86+
</div>
87+
</template>
88+
89+
<style scoped>
90+
.import-card {
91+
display: flex;
92+
flex-direction: column;
93+
align-items: center;
94+
text-align: center;
95+
width: 100%;
96+
height: 100%;
97+
gap: 0.4rem;
98+
padding: 1.25rem 1rem;
99+
background: var(--bs-body-bg, #fff);
100+
border: 1px solid var(--bs-border-color, #dee2e6);
101+
border-radius: 0.75rem;
102+
cursor: pointer;
103+
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
104+
}
105+
106+
.import-card:hover,
107+
.import-card:focus-visible {
108+
transform: translateY(-3px);
109+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
110+
border-color: var(--bs-primary, #0d6efd);
111+
outline: none;
112+
}
113+
114+
.import-card--disabled {
115+
cursor: not-allowed;
116+
opacity: 0.55;
117+
}
118+
119+
/* keep disabled cards flat — no hover lift, no primary accent */
120+
.import-card--disabled:hover,
121+
.import-card--disabled:focus-visible {
122+
transform: none;
123+
box-shadow: none;
124+
border-color: var(--bs-border-color, #dee2e6);
125+
}
126+
127+
.import-card--disabled .import-card-icon {
128+
color: var(--bs-secondary, #6c757d);
129+
}
130+
131+
.import-card-badge {
132+
margin-top: 0.15rem;
133+
font-weight: 500;
134+
}
135+
136+
.import-card-icon {
137+
font-size: 2.25rem;
138+
line-height: 1;
139+
color: var(--bs-primary, #0d6efd);
140+
}
141+
142+
.import-card-title {
143+
font-weight: 600;
144+
}
145+
146+
.import-card-desc {
147+
font-size: 0.8125rem;
148+
line-height: 1.3;
149+
}
150+
</style>

src/i18n/am.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,14 @@
108108
"url": "From URL (Markdown / ZIP)",
109109
"gist": "GitHub Gist",
110110
"localFolder": "Local folder",
111+
"title": "Create a new project",
112+
"newDesc": "Start with a blank document and write from scratch.",
113+
"uploadDesc": "Pick a Markdown file or a ZIP archive from your device.",
114+
"githubDesc": "Import the files of a public GitHub repository.",
115+
"urlDesc": "Load a Markdown file or ZIP archive from a web link.",
116+
"gistDesc": "Import all files of a GitHub Gist into a new project.",
117+
"localFolderDesc": "Open a folder on your computer (Chrome / Edge only).",
118+
"unavailable": "Not available in this browser",
111119
"githubPlaceholder": "owner/repo or https://github.com/owner/repo",
112120
"githubHint": "Pick the files to import on the next screen.",
113121
"urlPlaceholder": "https://.../README.md or .../project.zip",

0 commit comments

Comments
 (0)