feat: publish unlisted GTIN tool
@@ -91,9 +91,10 @@ fi
|
|||||||
|
|
||||||
printf '\n== routes\n'
|
printf '\n== routes\n'
|
||||||
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
|
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
|
||||||
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/; do
|
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ /kontakt/ /impressum/ /datenschutz/ /gtin/; do
|
||||||
check_status "$base$route" 200
|
check_status "$base$route" 200
|
||||||
done
|
done
|
||||||
|
check_redirect "$base/gtin" "$base/gtin/"
|
||||||
|
|
||||||
# The retired photo URL must keep working: it is the umbrella page now.
|
# The retired photo URL must keep working: it is the umbrella page now.
|
||||||
check_redirect "$base/shootings" "$base/fotografie/"
|
check_redirect "$base/shootings" "$base/fotografie/"
|
||||||
@@ -139,6 +140,18 @@ check_contains "$base/musik-und-buehne/" "Sagenbringer"
|
|||||||
check_contains "$base/impressum/" "§ 5 DDG"
|
check_contains "$base/impressum/" "§ 5 DDG"
|
||||||
check_contains "$base/impressum/" "DE462149560"
|
check_contains "$base/impressum/" "DE462149560"
|
||||||
check_contains "$base/datenschutz/" "keine Zugriffsprotokolle"
|
check_contains "$base/datenschutz/" "keine Zugriffsprotokolle"
|
||||||
|
check_contains "$base/datenschutz/" "bis zu 20"
|
||||||
|
check_contains "$base/impressum/" "GTIN-Tool"
|
||||||
|
check_contains "$base/gtin/" 'name="robots" content="noindex, nofollow"'
|
||||||
|
check_not_contains "$base/" 'href="/gtin'
|
||||||
|
check_not_contains "$base/sitemap.xml" "/gtin/"
|
||||||
|
check_contains "$base/gtin/" 'href="/impressum/"'
|
||||||
|
check_contains "$base/gtin/" 'href="/datenschutz/"'
|
||||||
|
for route in / /fotografie/ /businessfotografie/ /portrait-und-model/ /familie/ \
|
||||||
|
/minishootings/ /video/ /musik-und-buehne/ /social-media/ /projekte/ /ueber/ \
|
||||||
|
/kontakt/ /impressum/ /datenschutz/; do
|
||||||
|
check_not_contains "$base$route" 'href="/gtin'
|
||||||
|
done
|
||||||
check_contains "$base/datenschutz/" "youtube-nocookie.com"
|
check_contains "$base/datenschutz/" "youtube-nocookie.com"
|
||||||
check_contains "$base/video/" "NWWFTf7l8g0"
|
check_contains "$base/video/" "NWWFTf7l8g0"
|
||||||
check_contains "$base/" "https://www.dennyschulz.de/"
|
check_contains "$base/" "https://www.dennyschulz.de/"
|
||||||
@@ -157,6 +170,17 @@ check_status "$base/assets/img/logo-160.png" 200
|
|||||||
check_status "$base/assets/img/favicon-32.png" 200
|
check_status "$base/assets/img/favicon-32.png" 200
|
||||||
check_status "$base/assets/img/og-fotografie.jpg" 200
|
check_status "$base/assets/img/og-fotografie.jpg" 200
|
||||||
check_status "$base/assets/site.css" 200
|
check_status "$base/assets/site.css" 200
|
||||||
|
check_status "$base/gtin/css/app.css" 200
|
||||||
|
check_status "$base/gtin/js/app.js" 200
|
||||||
|
check_status "$base/gtin/sw.js" 200
|
||||||
|
check_status "$base/gtin/manifest.webmanifest" 200
|
||||||
|
check_status "$base/gtin/health" 404
|
||||||
|
manifest_type="$(curl -sSI --max-time 30 "$base/gtin/manifest.webmanifest" | tr -d '\r' | grep -i '^content-type:' || true)"
|
||||||
|
if printf '%s' "$manifest_type" | grep -qi 'application/manifest+json'; then
|
||||||
|
pass "$base/gtin/manifest.webmanifest uses the Web App Manifest MIME type"
|
||||||
|
else
|
||||||
|
fail "$base/gtin/manifest.webmanifest has wrong Content-Type: ${manifest_type:-<missing>}"
|
||||||
|
fi
|
||||||
check_status "$base/assets/fonts/fraunces-latin.woff2" 200
|
check_status "$base/assets/fonts/fraunces-latin.woff2" 200
|
||||||
# The portfolio expansion ships the operator's own public work...
|
# The portfolio expansion ships the operator's own public work...
|
||||||
check_status "$base/assets/img/music-dreadlocks-1200.webp" 200
|
check_status "$base/assets/img/music-dreadlocks-1200.webp" 200
|
||||||
|
|||||||
@@ -89,10 +89,17 @@ request and is the only permitted reference (see "Editing rules").
|
|||||||
## Privacy contract
|
## Privacy contract
|
||||||
The `/datenschutz/` page makes concrete promises. Any change that would break one of
|
The `/datenschutz/` page makes concrete promises. Any change that would break one of
|
||||||
them is a defect, not a style question:
|
them is a defect, not a style question:
|
||||||
- no cookies, no `localStorage`, no analytics, no tracking;
|
- no cookies, analytics or tracking. The standalone `/gtin/` tool is the only
|
||||||
|
`localStorage`/Cache API exception: it keeps its last 20 generated entries in
|
||||||
|
the browser and caches only its own offline app shell; it sends no tool data
|
||||||
|
to a server. The tool stays unlisted: no site navigation or page links, no
|
||||||
|
sitemap entry, and `noindex,nofollow`; only the tool itself links out to the
|
||||||
|
required legal pages;
|
||||||
- `access_log off` in `nginx.conf` — never enable request logging;
|
- `access_log off` in `nginx.conf` — never enable request logging;
|
||||||
- `error_log /dev/null crit;` in `nginx.conf` — never write an error-log file either;
|
- `error_log /dev/null crit;` in `nginx.conf` — never write an error-log file either;
|
||||||
- fonts, scripts, styles and images are served from this origin only;
|
- fonts, scripts, styles and images are served from this origin only;
|
||||||
|
- the GTIN service worker is scoped to `/gtin/` and may not intercept other site
|
||||||
|
routes or delete caches owned by another application;
|
||||||
- YouTube is embedded exclusively via the click-to-load facade
|
- YouTube is embedded exclusively via the click-to-load facade
|
||||||
(`youtube-nocookie.com`) — no iframe before the click, no preconnect;
|
(`youtube-nocookie.com`) — no iframe before the click, no preconnect;
|
||||||
- the contact form never posts anywhere; it composes a local `mailto:` draft
|
- the contact form never posts anywhere; it composes a local `mailto:` draft
|
||||||
|
|||||||
14
README.md
@@ -41,9 +41,13 @@ open http://127.0.0.1:18430/
|
|||||||
|
|
||||||
## Seiten
|
## Seiten
|
||||||
|
|
||||||
Startseite · Fotografie · Businessfotografie · Portrait & Model · Familien & Paare ·
|
Startseite · Fotografie · Businessfotografie · Portrait & Model · Familie · Minishootings ·
|
||||||
Minishootings · Video · Social Media · Projekte · Über · Kontakt · Impressum ·
|
Video · Social Media · Projekte · Über · Kontakt · Impressum · Datenschutz — plus eine eigene
|
||||||
Datenschutz — plus eine eigene 404-Seite.
|
404-Seite.
|
||||||
|
|
||||||
|
Der GTIN-Generator unter `/gtin/` ist ein separates, öffentlich direkt aufrufbares Werkzeug.
|
||||||
|
Er bleibt absichtlich unverlinkt, fehlt in der Sitemap und trägt `noindex,nofollow`; nur das
|
||||||
|
Werkzeug selbst verlinkt auf Impressum und Datenschutz.
|
||||||
|
|
||||||
## Technische Entscheidungen
|
## Technische Entscheidungen
|
||||||
|
|
||||||
@@ -59,6 +63,10 @@ Datenschutz — plus eine eigene 404-Seite.
|
|||||||
Es gibt keinen API-Endpunkt und keine Datenübertragung an den Server.
|
Es gibt keinen API-Endpunkt und keine Datenübertragung an den Server.
|
||||||
- **Keine Zugriffsprotokolle.** `nginx.conf` schaltet das Access-Log ab; es gibt
|
- **Keine Zugriffsprotokolle.** `nginx.conf` schaltet das Access-Log ab; es gibt
|
||||||
keine Cookies, keine Analyse und keine externen Ressourcen.
|
keine Cookies, keine Analyse und keine externen Ressourcen.
|
||||||
|
- **GTIN-Generator.** Der Generator unter `/gtin/` rechnet vollständig im Browser,
|
||||||
|
ohne Konto, externe API oder Serverübertragung. Er speichert bis zu 20 zuletzt
|
||||||
|
erzeugte Einträge lokal im Browser; der Offline-Appcache enthält nur die
|
||||||
|
Anwendungsdateien. Beide Funktionen sind in der Datenschutzerklärung beschrieben.
|
||||||
|
|
||||||
## Herkunft der Inhalte
|
## Herkunft der Inhalte
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ server {
|
|||||||
location = /impressum.html { return 301 /impressum/; }
|
location = /impressum.html { return 301 /impressum/; }
|
||||||
location = /datenschutz.html { return 301 /datenschutz/; }
|
location = /datenschutz.html { return 301 /datenschutz/; }
|
||||||
location = /kontakt.html { return 301 /kontakt/; }
|
location = /kontakt.html { return 301 /kontakt/; }
|
||||||
|
location = /gtin { return 301 /gtin/; }
|
||||||
|
location = /gtin/manifest.webmanifest {
|
||||||
|
default_type application/manifest+json;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
# /fotografie.html used to redirect to /shootings/; it resolves to its own
|
# /fotografie.html used to redirect to /shootings/; it resolves to its own
|
||||||
# canonical page now instead of turning into a 404.
|
# canonical page now instead of turning into a 404.
|
||||||
location = /fotografie.html { return 301 /fotografie/; }
|
location = /fotografie.html { return 301 /fotografie/; }
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from theme import CSS, TOKENS # noqa: E402
|
|||||||
|
|
||||||
WIDTHS = (480, 768, 1200, 1600)
|
WIDTHS = (480, 768, 1200, 1600)
|
||||||
WEBP_QUALITY = 84
|
WEBP_QUALITY = 84
|
||||||
|
UNLISTED_HTML = ["gtin/index.html"]
|
||||||
|
|
||||||
INK = (23, 20, 15)
|
INK = (23, 20, 15)
|
||||||
PAPER = (246, 242, 234)
|
PAPER = (246, 242, 234)
|
||||||
@@ -278,6 +279,9 @@ def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
|
|||||||
target.write_text(html, encoding="utf-8")
|
target.write_text(html, encoding="utf-8")
|
||||||
written.append((path, rel))
|
written.append((path, rel))
|
||||||
print(f" [html] {rel}")
|
print(f" [html] {rel}")
|
||||||
|
shutil.rmtree(SITE / "gtin", ignore_errors=True)
|
||||||
|
shutil.copytree(SRC / "gtin", SITE / "gtin")
|
||||||
|
print(" [html] gtin/index.html (unlisted browser tool)")
|
||||||
not_found = pages.not_found().replace("og-desfoto.jpg", "og-desfoto.jpg")
|
not_found = pages.not_found().replace("og-desfoto.jpg", "og-desfoto.jpg")
|
||||||
(SITE / "404.html").write_text(not_found, encoding="utf-8")
|
(SITE / "404.html").write_text(not_found, encoding="utf-8")
|
||||||
print(" [html] 404.html")
|
print(" [html] 404.html")
|
||||||
@@ -287,7 +291,7 @@ def build_html(table: dict[str, dict]) -> list[tuple[str, str]]:
|
|||||||
def build_meta_files(routes: list[tuple[str, str]]) -> None:
|
def build_meta_files(routes: list[tuple[str, str]]) -> None:
|
||||||
today = dt.date.today().isoformat()
|
today = dt.date.today().isoformat()
|
||||||
urls = []
|
urls = []
|
||||||
for path, _rel, _f, _x, priority in pages.ROUTES:
|
for path, _rel, _factory, _extra, priority in pages.ROUTES:
|
||||||
urls.append(
|
urls.append(
|
||||||
f" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
|
f" <url><loc>{SITE_META['url']}{path}</loc><lastmod>{today}</lastmod>"
|
||||||
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
|
f"<changefreq>{'weekly' if priority == '1.0' else 'monthly'}</changefreq>"
|
||||||
@@ -571,7 +575,11 @@ def prune_stale_pages(routes: list[tuple]) -> None:
|
|||||||
leave its old HTML (and its retired image srcsets) behind and the link check
|
leave its old HTML (and its retired image srcsets) behind and the link check
|
||||||
would keep failing on files nobody serves any more.
|
would keep failing on files nobody serves any more.
|
||||||
"""
|
"""
|
||||||
expected = {SITE / route[1] for route in routes} | {SITE / "404.html"}
|
expected = (
|
||||||
|
{SITE / route[1] for route in routes}
|
||||||
|
| {SITE / rel for rel in UNLISTED_HTML}
|
||||||
|
| {SITE / "404.html"}
|
||||||
|
)
|
||||||
for html in sorted(SITE.rglob("*.html")):
|
for html in sorted(SITE.rglob("*.html")):
|
||||||
if html in expected:
|
if html in expected:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Contact: mailto:info@dennyschulz.de
|
Contact: mailto:info@dennyschulz.de
|
||||||
Expires: 2027-09-19T00:00:00.000Z
|
Expires: 2027-09-25T00:00:00.000Z
|
||||||
Preferred-Languages: de, en
|
Preferred-Languages: de, en
|
||||||
Canonical: https://desfoto.de/.well-known/security.txt
|
Canonical: https://desfoto.de/.well-known/security.txt
|
||||||
|
|||||||
@@ -101,8 +101,11 @@
|
|||||||
|
|
||||||
<h2>5. Cookies und lokale Speicherung</h2>
|
<h2>5. Cookies und lokale Speicherung</h2>
|
||||||
<p>Diese Website setzt <strong>keine Cookies</strong> und verwendet keine Analyse-, Marketing- oder
|
<p>Diese Website setzt <strong>keine Cookies</strong> und verwendet keine Analyse-, Marketing- oder
|
||||||
Tracking-Technologien. Es werden auch keine Inhalte im lokalen Speicher deines Browsers abgelegt.
|
Tracking-Technologien. Das browserbasierte GTIN-Tool speichert die letzten bis zu 20
|
||||||
Ein Cookie-Banner ist deshalb nicht erforderlich.</p>
|
Artikelnummern und erzeugten GTINs im lokalen Speicher deines Browsers, damit du sie dort erneut
|
||||||
|
aufrufen oder den Verlauf löschen kannst. Die Einträge werden nicht an einen Server übertragen.
|
||||||
|
Für den Offlinebetrieb speichert dein Browser außerdem die Anwendungsdateien im Cache. Beides
|
||||||
|
kannst du über das Tool beziehungsweise die Browser-Einstellungen löschen.</p>
|
||||||
|
|
||||||
<h2>6. Schriftarten und Bilder</h2>
|
<h2>6. Schriftarten und Bilder</h2>
|
||||||
<p>Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.
|
<p>Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.
|
||||||
|
|||||||
774
site/gtin/css/app.css
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
/* GTIN Generator – bewusst klein, hell und scanfreundlich. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--schwarz: #111111;
|
||||||
|
--text: #111111;
|
||||||
|
--text-leise: #4b5563;
|
||||||
|
--linie: #d1d5db;
|
||||||
|
--flaeche: #ffffff;
|
||||||
|
--hintergrund: #f4f5f7;
|
||||||
|
--fokus: #1d4ed8;
|
||||||
|
--radius: 12px;
|
||||||
|
--abstand: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--hintergrund);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.scan-offen {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.liste-offen {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--fokus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) var(--abstand)
|
||||||
|
calc(var(--abstand) + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fuer Hilfstechnik vorhanden, ohne Platz auf dem Bildschirm zu kosten. */
|
||||||
|
.nur-vorlesen {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hinweis-badge {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte {
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--linie);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--abstand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld-titel {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 2px solid var(--linie);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld:focus {
|
||||||
|
border-color: var(--schwarz);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld-hinweis {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fehler {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ergebnis {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtin-typ {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtin {
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(1.9rem, 9vw, 2.75rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 4px;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen {
|
||||||
|
margin-top: var(--abstand);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen-reihe {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen-reihe .knopf {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf {
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid var(--schwarz);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf-stark {
|
||||||
|
background: var(--schwarz);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf-leise {
|
||||||
|
border-color: transparent;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-leise);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-titel {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-liste {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-liste li + li {
|
||||||
|
border-top: 1px solid var(--linie);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-eintrag {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-eintrag:hover,
|
||||||
|
.verlauf-eintrag:focus-visible {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-artikel {
|
||||||
|
min-width: 5.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-pfeil {
|
||||||
|
color: var(--text-leise);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-gtin {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-hinweis {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--schwarz);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopier-hilfe {
|
||||||
|
position: fixed;
|
||||||
|
top: -1000px;
|
||||||
|
left: -1000px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Scanmodus -- */
|
||||||
|
|
||||||
|
.scan {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) 12px
|
||||||
|
calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-innen {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-seite {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-barcode {
|
||||||
|
width: 100%;
|
||||||
|
/* Nimmt im Hochformat die verbleibende Hoehe ein, damit der Barcode so
|
||||||
|
gross wie moeglich wird. Die Groesse wird in app.js gemessen. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-barcode svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-gtin {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(1.5rem, 7vw, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-hinweis {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-zurueck {
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Querformat: Der Barcode bekommt die volle Breite des Bildschirms, GTIN und
|
||||||
|
* Zurueck-Schaltflaeche stehen daneben. Dadurch wird der grosse Code deutlich
|
||||||
|
* groesser als im Hochformat, wo die Beschriftung darunter liegt.
|
||||||
|
*/
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.scan-innen {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-barcode {
|
||||||
|
width: auto;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-seite {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 34%;
|
||||||
|
align-self: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-gtin {
|
||||||
|
font-size: clamp(1rem, 4.4vh, 1.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-hinweis {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-zurueck {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- Listenmodus -- */
|
||||||
|
|
||||||
|
/* Der Einstieg ist bewusst ein kleiner Link unter dem Werkzeug: Die
|
||||||
|
Einzelansicht bleibt die Hauptansicht. */
|
||||||
|
.fuss-liste {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-datenschutz {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-datenschutz a,
|
||||||
|
.fuss-rechtliches a {
|
||||||
|
color: inherit;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-rechtliches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-klein {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-klein:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--flaeche);
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) var(--abstand)
|
||||||
|
calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-innen {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 44rem;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-erfassen,
|
||||||
|
.liste-anzeige {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schritt 1: Nummern erfassen. */
|
||||||
|
|
||||||
|
.liste-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Abstaende liegen am Formular, nicht an den einzelnen Elementen. */
|
||||||
|
.liste-form .feld-titel,
|
||||||
|
.liste-form .feld-hinweis,
|
||||||
|
.liste-form .fehler {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Feld und Bestaetigung bilden eine Zeile: Der Knopf sitzt unmittelbar neben
|
||||||
|
* der Eingabe, damit das Bestaetigen auf dem Telefon nicht von der
|
||||||
|
* Bildschirmtastatur abhaengt. Auf sehr schmalen Anzeigen bricht er unter das
|
||||||
|
* Feld und bleibt dort ein volles Touchziel.
|
||||||
|
*/
|
||||||
|
.liste-eingabezeile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eingabezeile .feld {
|
||||||
|
flex: 1 1 9rem;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-hinzufuegen {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-titel {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-anzahl {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.6rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--schwarz);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintraege {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintrag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid var(--linie);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintrag:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-nr {
|
||||||
|
min-width: 5.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintrag .liste-gtin {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-weg {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-weg:hover,
|
||||||
|
.liste-weg:focus-visible {
|
||||||
|
background: var(--hintergrund);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-leer {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-aktionen,
|
||||||
|
.liste-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schritt 2: GTIN und Barcode nacheinander anzeigen. */
|
||||||
|
|
||||||
|
.liste-zaehler {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-buehne {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"mitte mitte"
|
||||||
|
"zurueck vor";
|
||||||
|
gap: 10px;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-mitte {
|
||||||
|
grid-area: mitte;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-barcode {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-barcode svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-gtin {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(1.4rem, 6.5vw, 2.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-pfeil {
|
||||||
|
min-height: 3rem;
|
||||||
|
border: 2px solid var(--schwarz);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#liste-zurueck {
|
||||||
|
grid-area: zurueck;
|
||||||
|
}
|
||||||
|
|
||||||
|
#liste-vor {
|
||||||
|
grid-area: vor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-artikel {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Schritt zur naechsten Nummer zeigt die Richtung: Der neue Barcode kommt
|
||||||
|
* von der Seite, in die der Nutzer geblättert hat. 160 ms, einmalig, und unter
|
||||||
|
* "prefers-reduced-motion: reduce" abgeschaltet.
|
||||||
|
*/
|
||||||
|
@keyframes liste-schritt-vor {
|
||||||
|
from {
|
||||||
|
transform: translateX(14px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes liste-schritt-zurueck {
|
||||||
|
from {
|
||||||
|
transform: translateX(-14px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-schritt-vor {
|
||||||
|
animation: liste-schritt-vor 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-schritt-zurueck {
|
||||||
|
animation: liste-schritt-zurueck 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Querformat: Die Pfeile stehen als Leisten neben dem Barcode, der dadurch die
|
||||||
|
* volle Hoehe bekommt.
|
||||||
|
*/
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.liste-buehne {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
grid-template-areas: "zurueck mitte vor";
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-pfeil {
|
||||||
|
width: 4rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.aktionen {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen .knopf-stark {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen-reihe {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
site/gtin/icons/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 32" role="img" aria-label="GTIN Generator"><rect width="35" height="32" fill="#ffffff"/><g fill="#111111"><rect x="0" y="0" width="1" height="32"/><rect x="2" y="0" width="1" height="32"/><rect x="5" y="0" width="1" height="32"/><rect x="7" y="0" width="2" height="32"/><rect x="11" y="0" width="1" height="32"/><rect x="14" y="0" width="1" height="32"/><rect x="16" y="0" width="1" height="32"/><rect x="19" y="0" width="2" height="32"/><rect x="22" y="0" width="1" height="32"/><rect x="25" y="0" width="1" height="32"/><rect x="27" y="0" width="2" height="32"/><rect x="30" y="0" width="2" height="32"/><rect x="33" y="0" width="1" height="32"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 715 B |
BIN
site/gtin/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 251 B |
BIN
site/gtin/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
site/gtin/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
190
site/gtin/index.html
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>GTIN Generator · desfoto</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen. Kostenlos, lokal im Browser und ohne Anmeldung."
|
||||||
|
/>
|
||||||
|
<link rel="canonical" href="https://desfoto.de/gtin/" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GTIN" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="./icons/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
|
<link rel="stylesheet" href="./css/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app">
|
||||||
|
<!-- Kein sichtbarer Titel und keine Beschreibung: Auf dem Bildschirm
|
||||||
|
zaehlt nur die Eingabe mit GTIN und Barcode. Die Ueberschrift bleibt
|
||||||
|
fuer Hilfstechnik vorhanden. -->
|
||||||
|
<h1 class="nur-vorlesen">GTIN Generator</h1>
|
||||||
|
<p class="hinweis-badge" id="offline-hinweis" hidden>Offline – die Berechnung läuft weiter.</p>
|
||||||
|
|
||||||
|
<section class="karte eingabe" aria-labelledby="eingabe-titel">
|
||||||
|
<h2 class="feld-titel" id="eingabe-titel">Artikelnummer</h2>
|
||||||
|
<input
|
||||||
|
class="feld"
|
||||||
|
id="artikelnummer"
|
||||||
|
name="artikelnummer"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
enterkeyhint="go"
|
||||||
|
placeholder="z. B. 5122"
|
||||||
|
aria-labelledby="eingabe-titel"
|
||||||
|
aria-describedby="format-hinweis"
|
||||||
|
/>
|
||||||
|
<p class="feld-hinweis" id="format-hinweis">Erlaubt sind 3, 4 oder 7 Ziffern.</p>
|
||||||
|
<p class="fehler" id="fehler" role="alert" hidden></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="karte ergebnis" id="ergebnis" aria-live="polite" hidden>
|
||||||
|
<p class="gtin-typ" id="gtin-typ"></p>
|
||||||
|
<button class="gtin" id="gtin" type="button" title="GTIN kopieren"></button>
|
||||||
|
<div class="barcode" id="barcode"></div>
|
||||||
|
<div class="aktionen">
|
||||||
|
<button class="knopf knopf-stark" id="gross" type="button">Barcode groß anzeigen</button>
|
||||||
|
<div class="aktionen-reihe">
|
||||||
|
<button class="knopf" id="kopieren" type="button">GTIN kopieren</button>
|
||||||
|
<button class="knopf" id="neu" type="button">Neue Artikelnummer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="karte verlauf" id="verlauf" hidden aria-labelledby="verlauf-titel">
|
||||||
|
<h2 class="verlauf-titel" id="verlauf-titel">Zuletzt verwendet</h2>
|
||||||
|
<ul class="verlauf-liste" id="verlauf-liste"></ul>
|
||||||
|
<button class="knopf knopf-leise" id="verlauf-loeschen" type="button">Verlauf löschen</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="fuss">
|
||||||
|
<button class="knopf knopf-stark" id="installieren" type="button" hidden>
|
||||||
|
App installieren
|
||||||
|
</button>
|
||||||
|
<p class="fuss-hinweis" id="ios-hinweis" hidden>
|
||||||
|
Installieren: Teilen → „Zum Home-Bildschirm“.
|
||||||
|
</p>
|
||||||
|
<!-- Kleiner Einstieg in den optionalen Listenmodus: Die Einzelansicht
|
||||||
|
bleibt unveraendert, die Liste liegt darunter als Link. -->
|
||||||
|
<p class="fuss-liste">
|
||||||
|
<button class="link-klein" id="liste-oeffnen" type="button" aria-haspopup="dialog">
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="fuss-datenschutz">
|
||||||
|
Eingaben und Verlauf bleiben in deinem Browser.
|
||||||
|
<a href="/datenschutz/">Datenschutz</a>
|
||||||
|
</p>
|
||||||
|
<nav class="fuss-rechtliches" aria-label="Rechtliche Informationen">
|
||||||
|
<a href="/impressum/">Impressum</a>
|
||||||
|
<a href="/datenschutz/">Datenschutz</a>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="scan" id="scan" role="dialog" aria-modal="true" aria-label="Barcode im Scanmodus" hidden>
|
||||||
|
<div class="scan-innen" id="scan-innen" tabindex="-1">
|
||||||
|
<div class="scan-barcode" id="scan-barcode"></div>
|
||||||
|
<div class="scan-seite">
|
||||||
|
<p class="scan-gtin" id="scan-gtin"></p>
|
||||||
|
<p class="scan-hinweis" id="scan-hinweis" hidden>
|
||||||
|
Barcode wird nicht erkannt? Displayhelligkeit erhöhen.
|
||||||
|
</p>
|
||||||
|
<button class="knopf knopf-stark scan-zurueck" id="scan-zurueck" type="button">Zurück</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="toast" id="toast" role="status" aria-live="polite" hidden></p>
|
||||||
|
|
||||||
|
<div class="liste" id="liste" role="dialog" aria-modal="true" aria-label="Liste" hidden>
|
||||||
|
<div class="liste-innen" id="liste-innen">
|
||||||
|
<!-- Schritt 1: Nummern eingeben, jede ueber die Schaltflaeche oder die
|
||||||
|
Bestaetigungstaste der Bildschirmtastatur in die Liste uebernehmen. -->
|
||||||
|
<section class="liste-erfassen" id="liste-erfassen" aria-labelledby="liste-titel">
|
||||||
|
<h2 class="liste-titel" id="liste-titel">
|
||||||
|
Liste <span class="liste-anzahl" id="liste-anzahl">0</span>
|
||||||
|
</h2>
|
||||||
|
<!-- Ein echtes Formular: Ein Numpad auf dem Telefon hat keine
|
||||||
|
Eingabetaste, seine Bestaetigungstaste (Haekchen) loest aber das
|
||||||
|
submit-Ereignis aus. Der sichtbare Knopf deckt jedes Geraet ab. -->
|
||||||
|
<form class="liste-form" id="liste-form" novalidate>
|
||||||
|
<label class="feld-titel" for="liste-eingabe">Artikelnummer</label>
|
||||||
|
<div class="liste-eingabezeile">
|
||||||
|
<input
|
||||||
|
class="feld"
|
||||||
|
id="liste-eingabe"
|
||||||
|
name="liste-eingabe"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
enterkeyhint="done"
|
||||||
|
placeholder="z. B. 5122"
|
||||||
|
aria-describedby="liste-format"
|
||||||
|
/>
|
||||||
|
<button class="knopf knopf-stark liste-hinzufuegen" id="liste-hinzufuegen" type="submit">
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="feld-hinweis" id="liste-format">
|
||||||
|
Hinzufügen übernimmt die Nummer; mehrere Nummern dürfen durch Leerzeichen getrennt sein.
|
||||||
|
</p>
|
||||||
|
<p class="fehler" id="liste-fehler" role="alert" hidden></p>
|
||||||
|
</form>
|
||||||
|
<ul class="liste-eintraege" id="liste-eintraege"></ul>
|
||||||
|
<p class="liste-leer" id="liste-leer">Noch keine Nummer in der Liste.</p>
|
||||||
|
<div class="liste-aktionen">
|
||||||
|
<button class="knopf" id="liste-weiter" type="button" disabled>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
<button class="link-klein" id="liste-schliessen" type="button">
|
||||||
|
Zurück zur Einzelansicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Schritt 2: GTIN und Barcode nacheinander anzeigen. -->
|
||||||
|
<section class="liste-anzeige" id="liste-anzeige" aria-label="Barcodes der Liste" hidden>
|
||||||
|
<p class="liste-zaehler" id="liste-zaehler"></p>
|
||||||
|
<div class="liste-buehne" id="liste-buehne">
|
||||||
|
<button class="liste-pfeil" id="liste-zurueck" type="button" aria-label="Vorheriger Eintrag">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div class="liste-mitte" id="liste-mitte" tabindex="-1">
|
||||||
|
<div class="liste-barcode" id="liste-barcode"></div>
|
||||||
|
<p class="liste-gtin" id="liste-gtin" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
<button class="liste-pfeil" id="liste-vor" type="button" aria-label="Nächster Eintrag">
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="liste-artikel" id="liste-artikel"></p>
|
||||||
|
<div class="liste-links">
|
||||||
|
<button class="link-klein" id="liste-aendern" type="button">Liste ändern</button>
|
||||||
|
<button class="link-klein" id="liste-beenden" type="button">
|
||||||
|
Zurück zur Einzelansicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
998
site/gtin/js/app.js
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
/**
|
||||||
|
* Oberflaeche, Scanmodus, Zwischenablage, Verlauf, Wake Lock und PWA.
|
||||||
|
*
|
||||||
|
* Die fachliche Logik liegt in gtin.js, die Codierung in barcode.js. Diese
|
||||||
|
* Datei verdrahtet beides mit dem DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { eingabeStatus, erzeugeGtin, FEHLERTEXT, nurZiffern } from './gtin.js';
|
||||||
|
import { barcodeGroesse, erzeugeSvgMarkup, kodiereEan } from './barcode.js';
|
||||||
|
import { schritt, istBestaetigungstaste, zaehlerText, zerlegeEingabe } from './liste.js';
|
||||||
|
|
||||||
|
/** @typedef {import('./gtin.js').GtinErgebnis} GtinErgebnis */
|
||||||
|
|
||||||
|
/** @typedef {{ artikel: string, gtin: string, typ: string }} VerlaufEintrag */
|
||||||
|
/** @typedef {{ released: boolean, release: () => Promise<void>, addEventListener: (typ: 'release', hoerer: () => void) => void }} WakeLockLike */
|
||||||
|
|
||||||
|
const VERLAUF_SCHLUESSEL = 'desfoto.gtin.verlauf.v1';
|
||||||
|
const VERLAUF_MAX = 20;
|
||||||
|
const EINGABE_VERZOEGERUNG_MS = 450;
|
||||||
|
const VERLAUF_VERZOEGERUNG_MS = 2500;
|
||||||
|
const SCAN_HINWEIS_MS = 7000;
|
||||||
|
const TOAST_MS = 1800;
|
||||||
|
/** Anteil der Bildschirmhoehe, den der Barcode in der Normalansicht nutzen darf. */
|
||||||
|
const BALKENHOEHE_ANTEIL = 0.45;
|
||||||
|
/** Untergrenze, damit der Barcode auch auf kurzen Anzeigen scanbar bleibt. */
|
||||||
|
const BALKENHOEHE_MIN_PX = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} auswahl
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function element(auswahl) {
|
||||||
|
const gefunden = document.querySelector(auswahl);
|
||||||
|
if (!gefunden) throw new Error(`Pflichtelement fehlt: ${auswahl}`);
|
||||||
|
return /** @type {HTMLElement} */ (gefunden);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = /** @type {HTMLInputElement} */ (element('#artikelnummer'));
|
||||||
|
const fehlerText = element('#fehler');
|
||||||
|
const ergebnisBereich = element('#ergebnis');
|
||||||
|
const gtinTyp = element('#gtin-typ');
|
||||||
|
const gtinKnopf = element('#gtin');
|
||||||
|
const barcodeBehaelter = element('#barcode');
|
||||||
|
const grossKnopf = element('#gross');
|
||||||
|
const kopierenKnopf = element('#kopieren');
|
||||||
|
const neuKnopf = element('#neu');
|
||||||
|
const verlaufBereich = element('#verlauf');
|
||||||
|
const verlaufListe = element('#verlauf-liste');
|
||||||
|
const verlaufLoeschenKnopf = element('#verlauf-loeschen');
|
||||||
|
const installierenKnopf = element('#installieren');
|
||||||
|
const iosHinweis = element('#ios-hinweis');
|
||||||
|
const offlineHinweis = element('#offline-hinweis');
|
||||||
|
const scanBereich = element('#scan');
|
||||||
|
const scanInnen = element('#scan-innen');
|
||||||
|
const scanBarcodeBehaelter = element('#scan-barcode');
|
||||||
|
const scanGtin = element('#scan-gtin');
|
||||||
|
const scanHinweis = element('#scan-hinweis');
|
||||||
|
const scanZurueckKnopf = element('#scan-zurueck');
|
||||||
|
const toast = element('#toast');
|
||||||
|
const appBereich = element('.app');
|
||||||
|
const listeOeffnenKnopf = element('#liste-oeffnen');
|
||||||
|
const listeBereich = element('#liste');
|
||||||
|
const listeForm = /** @type {HTMLFormElement} */ (element('#liste-form'));
|
||||||
|
const listeErfassen = element('#liste-erfassen');
|
||||||
|
const listeAnzeige = element('#liste-anzeige');
|
||||||
|
const listeEingabe = /** @type {HTMLInputElement} */ (element('#liste-eingabe'));
|
||||||
|
const listeHinzufuegenKnopf = /** @type {HTMLButtonElement} */ (element('#liste-hinzufuegen'));
|
||||||
|
const listeFehler = element('#liste-fehler');
|
||||||
|
const listeEintraege = element('#liste-eintraege');
|
||||||
|
const listeLeer = element('#liste-leer');
|
||||||
|
const listeAnzahl = element('#liste-anzahl');
|
||||||
|
const listeWeiterKnopf = /** @type {HTMLButtonElement} */ (element('#liste-weiter'));
|
||||||
|
const listeSchliessenKnopf = element('#liste-schliessen');
|
||||||
|
const listeZaehler = element('#liste-zaehler');
|
||||||
|
const listeBuehne = element('#liste-buehne');
|
||||||
|
const listeMitte = element('#liste-mitte');
|
||||||
|
const listeBarcodeBehaelter = element('#liste-barcode');
|
||||||
|
const listeGtin = element('#liste-gtin');
|
||||||
|
const listeArtikel = element('#liste-artikel');
|
||||||
|
const listeZurueckKnopf = element('#liste-zurueck');
|
||||||
|
const listeVorKnopf = element('#liste-vor');
|
||||||
|
const listeAendernKnopf = element('#liste-aendern');
|
||||||
|
const listeBeendenKnopf = element('#liste-beenden');
|
||||||
|
|
||||||
|
/** @type {GtinErgebnis | null} */
|
||||||
|
let aktuell = null;
|
||||||
|
/** @type {GtinErgebnis[]} */
|
||||||
|
const liste = [];
|
||||||
|
let listeAktiv = false;
|
||||||
|
let listeAnzeigeAktiv = false;
|
||||||
|
let listeIndex = 0;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let eingabeTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let verlaufTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let toastTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let scanHinweisTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let groessenTimer;
|
||||||
|
/** @type {WakeLockLike | null} */
|
||||||
|
let wakeLock = null;
|
||||||
|
let scanAktiv = false;
|
||||||
|
/** @type {any} */
|
||||||
|
let installationsAnfrage = null;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Verlauf -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} eintrag
|
||||||
|
* @returns {eintrag is VerlaufEintrag}
|
||||||
|
*/
|
||||||
|
function istVerlaufEintrag(eintrag) {
|
||||||
|
if (typeof eintrag !== 'object' || eintrag === null) return false;
|
||||||
|
const kandidat = /** @type {Record<string, unknown>} */ (eintrag);
|
||||||
|
return (
|
||||||
|
typeof kandidat.artikel === 'string' &&
|
||||||
|
typeof kandidat.gtin === 'string' &&
|
||||||
|
typeof kandidat.typ === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {VerlaufEintrag[]} */
|
||||||
|
function ladeVerlauf() {
|
||||||
|
try {
|
||||||
|
const roh = window.localStorage.getItem(VERLAUF_SCHLUESSEL);
|
||||||
|
if (!roh) return [];
|
||||||
|
const daten = /** @type {unknown} */ (JSON.parse(roh));
|
||||||
|
if (!Array.isArray(daten)) return [];
|
||||||
|
return daten.filter(istVerlaufEintrag).slice(0, VERLAUF_MAX);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {VerlaufEintrag[]} eintraege */
|
||||||
|
function speichereVerlaufListe(eintraege) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(VERLAUF_SCHLUESSEL, JSON.stringify(eintraege));
|
||||||
|
} catch {
|
||||||
|
/* Speicher gesperrt oder voll: der Verlauf ist optional. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {GtinErgebnis} ergebnis */
|
||||||
|
function merkeImVerlauf(ergebnis) {
|
||||||
|
const eintrag = { artikel: ergebnis.eingabe, gtin: ergebnis.gtin, typ: ergebnis.typ };
|
||||||
|
const bisher = ladeVerlauf().filter((vorhanden) => vorhanden.gtin !== eintrag.gtin);
|
||||||
|
speichereVerlaufListe([eintrag, ...bisher].slice(0, VERLAUF_MAX));
|
||||||
|
rendereVerlauf();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendereVerlauf() {
|
||||||
|
const eintraege = ladeVerlauf();
|
||||||
|
verlaufListe.replaceChildren();
|
||||||
|
for (const eintrag of eintraege) {
|
||||||
|
const punkt = document.createElement('li');
|
||||||
|
const knopf = document.createElement('button');
|
||||||
|
knopf.type = 'button';
|
||||||
|
knopf.className = 'verlauf-eintrag';
|
||||||
|
knopf.dataset.artikel = eintrag.artikel;
|
||||||
|
knopf.dataset.gtin = eintrag.gtin;
|
||||||
|
|
||||||
|
const artikel = document.createElement('span');
|
||||||
|
artikel.className = 'verlauf-artikel';
|
||||||
|
artikel.textContent = eintrag.artikel;
|
||||||
|
|
||||||
|
const pfeil = document.createElement('span');
|
||||||
|
pfeil.className = 'verlauf-pfeil';
|
||||||
|
pfeil.setAttribute('aria-hidden', 'true');
|
||||||
|
pfeil.textContent = '→';
|
||||||
|
|
||||||
|
const nummer = document.createElement('span');
|
||||||
|
nummer.className = 'verlauf-gtin';
|
||||||
|
nummer.textContent = eintrag.gtin;
|
||||||
|
|
||||||
|
knopf.append(artikel, pfeil, nummer);
|
||||||
|
knopf.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`Artikelnummer ${eintrag.artikel}, GTIN ${eintrag.gtin} erneut anzeigen`,
|
||||||
|
);
|
||||||
|
punkt.append(knopf);
|
||||||
|
verlaufListe.append(punkt);
|
||||||
|
}
|
||||||
|
verlaufBereich.hidden = eintraege.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leereVerlauf() {
|
||||||
|
speichereVerlaufListe([]);
|
||||||
|
rendereVerlauf();
|
||||||
|
zeigeToast('Verlauf gelöscht');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Anzeige -- */
|
||||||
|
|
||||||
|
function versteckeFehler() {
|
||||||
|
fehlerText.hidden = true;
|
||||||
|
fehlerText.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeFehler() {
|
||||||
|
fehlerText.textContent = FEHLERTEXT;
|
||||||
|
fehlerText.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versteckeErgebnis() {
|
||||||
|
aktuell = null;
|
||||||
|
ergebnisBereich.hidden = true;
|
||||||
|
barcodeBehaelter.replaceChildren();
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verfuegbare Hoehe fuer den Barcode in der Normalansicht. */
|
||||||
|
function normalHoehe() {
|
||||||
|
return Math.max(
|
||||||
|
BALKENHOEHE_MIN_PX,
|
||||||
|
Math.round(window.innerHeight * BALKENHOEHE_ANTEIL),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} behaelter
|
||||||
|
* @param {GtinErgebnis} ergebnis
|
||||||
|
* @param {'normal' | 'scan'} modus
|
||||||
|
*/
|
||||||
|
function rendereBarcode(behaelter, ergebnis, modus) {
|
||||||
|
const symbol = kodiereEan(ergebnis.gtin);
|
||||||
|
const gesamtModule =
|
||||||
|
symbol.quietzone.links + symbol.module + symbol.quietzone.rechts;
|
||||||
|
const gemessen = behaelter.getBoundingClientRect().width || behaelter.clientWidth;
|
||||||
|
const verfuegbareBreite = Math.max(1, Math.round(gemessen || 320));
|
||||||
|
// Im Scanmodus bestimmt die gemessene Flaeche die Groesse (Querformat: volle
|
||||||
|
// Breite und Hoehe). In der Normalansicht deckelt ein Anteil der
|
||||||
|
// Bildschirmhoehe den Barcode, damit die wichtigsten Bedienelemente ohne
|
||||||
|
// Scrollen sichtbar bleiben.
|
||||||
|
const verfuegbareHoehe =
|
||||||
|
modus === 'scan'
|
||||||
|
? Math.max(1, Math.round(behaelter.clientHeight || normalHoehe()))
|
||||||
|
: normalHoehe();
|
||||||
|
const { modulbreite: modulbreitePx, balkenhoeheModule } = barcodeGroesse(
|
||||||
|
verfuegbareBreite,
|
||||||
|
verfuegbareHoehe,
|
||||||
|
gesamtModule,
|
||||||
|
);
|
||||||
|
const svg = erzeugeSvgMarkup(ergebnis.gtin, {
|
||||||
|
modulbreite: modulbreitePx,
|
||||||
|
balkenhoeheModule,
|
||||||
|
});
|
||||||
|
behaelter.innerHTML = svg.markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoppeVerlaufTimer() {
|
||||||
|
if (verlaufTimer !== undefined) {
|
||||||
|
window.clearTimeout(verlaufTimer);
|
||||||
|
verlaufTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uebernimmt das aktuelle Ergebnis in den Verlauf, sobald es bestaetigt wirkt. */
|
||||||
|
function merkeAktuellesNachKurzerPause() {
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
verlaufTimer = window.setTimeout(() => {
|
||||||
|
verlaufTimer = undefined;
|
||||||
|
if (aktuell) merkeImVerlauf(aktuell);
|
||||||
|
}, VERLAUF_VERZOEGERUNG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {GtinErgebnis} ergebnis */
|
||||||
|
function zeigeErgebnis(ergebnis) {
|
||||||
|
aktuell = ergebnis;
|
||||||
|
versteckeFehler();
|
||||||
|
gtinTyp.textContent = ergebnis.typ === 'EAN-8' ? 'GTIN-8' : 'GTIN-13';
|
||||||
|
gtinKnopf.textContent = ergebnis.gtin;
|
||||||
|
ergebnisBereich.hidden = false;
|
||||||
|
rendereBarcode(barcodeBehaelter, ergebnis, 'normal');
|
||||||
|
merkeAktuellesNachKurzerPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefeUndZeige() {
|
||||||
|
const status = eingabeStatus(input.value);
|
||||||
|
if (status === 'leer') {
|
||||||
|
versteckeFehler();
|
||||||
|
versteckeErgebnis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auch eine zu kurze Nummer ist am Ende ungueltig. Der Fehler erscheint erst
|
||||||
|
// nach der Entprellung, waehrend des Tippens also nicht.
|
||||||
|
if (status === 'unvollstaendig' || status === 'ungueltig') {
|
||||||
|
versteckeErgebnis();
|
||||||
|
zeigeFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ergebnis = erzeugeGtin(input.value);
|
||||||
|
if (!ergebnis) {
|
||||||
|
versteckeErgebnis();
|
||||||
|
zeigeFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zeigeErgebnis(ergebnis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Eingabe -- */
|
||||||
|
|
||||||
|
function stoppeEingabeTimer() {
|
||||||
|
if (eingabeTimer !== undefined) {
|
||||||
|
window.clearTimeout(eingabeTimer);
|
||||||
|
eingabeTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beiEingabe() {
|
||||||
|
const sauber = nurZiffern(input.value);
|
||||||
|
if (sauber !== input.value) {
|
||||||
|
input.value = sauber;
|
||||||
|
input.setSelectionRange(sauber.length, sauber.length);
|
||||||
|
}
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
if (sauber.length === 0) {
|
||||||
|
versteckeFehler();
|
||||||
|
versteckeErgebnis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sauber.length === 7) {
|
||||||
|
pruefeUndZeige();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eingabeTimer = window.setTimeout(() => {
|
||||||
|
eingabeTimer = undefined;
|
||||||
|
pruefeUndZeige();
|
||||||
|
}, EINGABE_VERZOEGERUNG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebernimmt eine Artikelnummer in das Eingabefeld.
|
||||||
|
*
|
||||||
|
* @param {string} artikel
|
||||||
|
* @param {{ fokussieren?: boolean }} [optionen] `fokussieren: false` laesst den
|
||||||
|
* Fokus, wo er ist: Ein Tipp auf den Verlauf soll den Barcode zeigen und auf
|
||||||
|
* Mobilgeraeten nicht die Tastatur ueber das Ergebnis legen.
|
||||||
|
*/
|
||||||
|
function uebernimmArtikel(artikel, optionen = {}) {
|
||||||
|
input.value = artikel;
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
pruefeUndZeige();
|
||||||
|
if (optionen.fokussieren ?? true) input.focus();
|
||||||
|
// Der Verlauf steht unter dem Ergebnis: Nach dem Uebernehmen dorthin
|
||||||
|
// scrollen, damit der erzeugte Barcode sichtbar ist.
|
||||||
|
ergebnisBereich.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- Feld -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert die komplette Artikelnummer.
|
||||||
|
*
|
||||||
|
* Ein Tipp auf das Feld waehlt damit die alte Nummer aus: Die naechste Ziffer
|
||||||
|
* ueberschreibt sie direkt, ohne dass vorher geloescht werden muss.
|
||||||
|
*/
|
||||||
|
function markiereEingabe() {
|
||||||
|
if (input.value === '') {
|
||||||
|
versteckeFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.setSelectionRange(0, input.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, wenn das Feld beim letzten Zeigerdruck bereits den Fokus hatte. */
|
||||||
|
let feldWarFokussiert = false;
|
||||||
|
|
||||||
|
function neueNummer() {
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
input.value = '';
|
||||||
|
versteckeFehler();
|
||||||
|
versteckeErgebnis();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Scanmodus -- */
|
||||||
|
|
||||||
|
/** @returns {{ request: (typ: 'screen') => Promise<WakeLockLike> } | null} */
|
||||||
|
function wakeLockApi() {
|
||||||
|
const api = /** @type {any} */ (navigator).wakeLock;
|
||||||
|
return api && typeof api.request === 'function' ? api : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function starteWakeLock() {
|
||||||
|
const api = wakeLockApi();
|
||||||
|
if (!api || wakeLock) return;
|
||||||
|
api
|
||||||
|
.request('screen')
|
||||||
|
.then((sentinel) => {
|
||||||
|
wakeLock = sentinel;
|
||||||
|
sentinel.addEventListener('release', () => {
|
||||||
|
if (wakeLock === sentinel) wakeLock = null;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
wakeLock = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoppeWakeLock() {
|
||||||
|
const sentinel = wakeLock;
|
||||||
|
wakeLock = null;
|
||||||
|
if (sentinel) sentinel.release().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oeffneScan() {
|
||||||
|
if (!aktuell || scanAktiv) return;
|
||||||
|
scanAktiv = true;
|
||||||
|
scanGtin.textContent = aktuell.gtin;
|
||||||
|
scanBereich.hidden = false;
|
||||||
|
document.body.classList.add('scan-offen');
|
||||||
|
// Der Rest der Oberflaeche wird unerreichbar: Tastatur und Hilfstechnik
|
||||||
|
// bleiben im Scanmodus, weil nur noch der Dialog bedienbar ist.
|
||||||
|
appBereich.toggleAttribute('inert', true);
|
||||||
|
rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
|
||||||
|
scanInnen.focus();
|
||||||
|
starteWakeLock();
|
||||||
|
scanHinweisTimer = window.setTimeout(() => {
|
||||||
|
scanHinweisTimer = undefined;
|
||||||
|
if (scanAktiv) scanHinweis.hidden = false;
|
||||||
|
}, SCAN_HINWEIS_MS);
|
||||||
|
merkeImVerlauf(aktuell);
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseScan() {
|
||||||
|
if (!scanAktiv) return;
|
||||||
|
scanAktiv = false;
|
||||||
|
scanBereich.hidden = true;
|
||||||
|
scanHinweis.hidden = true;
|
||||||
|
appBereich.toggleAttribute('inert', false);
|
||||||
|
document.body.classList.remove('scan-offen');
|
||||||
|
if (scanHinweisTimer !== undefined) {
|
||||||
|
window.clearTimeout(scanHinweisTimer);
|
||||||
|
scanHinweisTimer = undefined;
|
||||||
|
}
|
||||||
|
stoppeWakeLock();
|
||||||
|
grossKnopf.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Listenmodus -- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Optionaler Modus: Die Liste liegt als kleiner Link unter dem Werkzeug. Sie
|
||||||
|
* wird in zwei Schritten bedient - Nummern bestaetigen, dann mit Pfeilen,
|
||||||
|
* Pfeiltasten oder Wischen GTIN und Barcode nacheinander anzeigen. Beide
|
||||||
|
* Schritte enden ueber die kleinen Links zurueck in die Einzelansicht.
|
||||||
|
*
|
||||||
|
* Bestaetigt wird ueber das Formular: Auf einem Telefon hat das Numpad keine
|
||||||
|
* Eingabetaste, seine Bestaetigungstaste loest aber das submit-Ereignis aus.
|
||||||
|
* Zusaetzlich liegt unmittelbar neben dem Feld eine sichtbare Schaltflaeche,
|
||||||
|
* damit die Bestaetigung auf keinem Geraet von der Tastatur abhaengt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function versteckeListenFehler() {
|
||||||
|
listeFehler.hidden = true;
|
||||||
|
listeFehler.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeListenFehler() {
|
||||||
|
listeFehler.textContent = FEHLERTEXT;
|
||||||
|
listeFehler.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendereListe() {
|
||||||
|
listeEintraege.replaceChildren();
|
||||||
|
liste.forEach((eintrag, index) => {
|
||||||
|
const punkt = document.createElement('li');
|
||||||
|
punkt.className = 'liste-eintrag';
|
||||||
|
|
||||||
|
const nummer = document.createElement('span');
|
||||||
|
nummer.className = 'liste-nr';
|
||||||
|
nummer.textContent = eintrag.eingabe;
|
||||||
|
|
||||||
|
const gtin = document.createElement('span');
|
||||||
|
gtin.className = 'liste-gtin';
|
||||||
|
gtin.textContent = eintrag.gtin;
|
||||||
|
|
||||||
|
const weg = document.createElement('button');
|
||||||
|
weg.type = 'button';
|
||||||
|
weg.className = 'liste-weg';
|
||||||
|
weg.dataset.index = String(index);
|
||||||
|
weg.setAttribute('aria-label', `Artikelnummer ${eintrag.eingabe} aus der Liste entfernen`);
|
||||||
|
weg.textContent = '×';
|
||||||
|
|
||||||
|
punkt.append(nummer, gtin, weg);
|
||||||
|
listeEintraege.append(punkt);
|
||||||
|
});
|
||||||
|
|
||||||
|
listeAnzahl.textContent = String(liste.length);
|
||||||
|
listeLeer.hidden = liste.length > 0;
|
||||||
|
listeWeiterKnopf.disabled = liste.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebernimmt die Eingabe in die Liste.
|
||||||
|
*
|
||||||
|
* Alles, was keine Ziffer ist, trennt Nummern: So laesst sich eine ganze Spalte
|
||||||
|
* in einem Zug einfuegen. Die Bestaetigung im wirklich leeren Feld startet
|
||||||
|
* direkt die Anzeige, damit die Erfassung ohne Klick auf "Weiter" endet. Steht
|
||||||
|
* dagegen Text ohne eine einzige Ziffer im Feld, ist das ein Fehler und kein
|
||||||
|
* Start.
|
||||||
|
*/
|
||||||
|
function uebernimmListeneingabe() {
|
||||||
|
const roh = listeEingabe.value;
|
||||||
|
const gruppen = zerlegeEingabe(roh);
|
||||||
|
if (gruppen.length === 0) {
|
||||||
|
if (roh.trim() !== '') {
|
||||||
|
zeigeListenFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versteckeListenFehler();
|
||||||
|
if (liste.length > 0) starteListenAnzeige(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let uebernommen = 0;
|
||||||
|
let verworfen = 0;
|
||||||
|
for (const gruppe of gruppen) {
|
||||||
|
const ergebnis = erzeugeGtin(gruppe);
|
||||||
|
if (ergebnis) {
|
||||||
|
liste.push(ergebnis);
|
||||||
|
uebernommen += 1;
|
||||||
|
} else {
|
||||||
|
verworfen += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uebernommen > 0) {
|
||||||
|
listeEingabe.value = '';
|
||||||
|
rendereListe();
|
||||||
|
listeEintraege.scrollTop = listeEintraege.scrollHeight;
|
||||||
|
// Der Fokus bleibt im Feld: Die Bildschirmtastatur bleibt offen, und die
|
||||||
|
// naechste Nummer laesst sich ohne einen weiteren Tipp eingeben.
|
||||||
|
listeEingabe.focus();
|
||||||
|
}
|
||||||
|
if (verworfen > 0) zeigeListenFehler();
|
||||||
|
else versteckeListenFehler();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestaetigt die Erfassung ueber das Formular.
|
||||||
|
*
|
||||||
|
* Ein Numpad auf dem Telefon bestaetigt mit seinem eigenen Schluessel
|
||||||
|
* (Haekchen, "Fertig", "Los"); er loest das submit-Ereignis aus. Hier laufen
|
||||||
|
* deshalb Bestaetigungstaste, eingefuegte Spalte und Klick auf "Hinzufuegen"
|
||||||
|
* zusammen.
|
||||||
|
*
|
||||||
|
* @param {SubmitEvent} ereignis
|
||||||
|
*/
|
||||||
|
function beiListenAbsenden(ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
// Ein leerer Aufruf ueber die Schaltflaeche springt nicht in die Anzeige:
|
||||||
|
// Wer "Hinzufuegen" tippt, will etwas hinzufuegen. Die Bestaetigungstaste im
|
||||||
|
// leeren Feld bleibt dagegen der schnelle Weg nach vorn.
|
||||||
|
if (ereignis.submitter === listeHinzufuegenKnopf && listeEingabe.value.trim() === '') {
|
||||||
|
listeEingabe.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uebernimmListeneingabe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt einen Eintrag; mit der Tastatur bleibt der Fokus in der Liste.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @param {boolean} ueberTastatur
|
||||||
|
*/
|
||||||
|
function entferneListenEintrag(index, ueberTastatur) {
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= liste.length) return;
|
||||||
|
const [entfernt] = liste.splice(index, 1);
|
||||||
|
rendereListe();
|
||||||
|
zeigeToast(`Artikelnummer ${entfernt.eingabe} entfernt`);
|
||||||
|
if (ueberTastatur) {
|
||||||
|
const naechster = listeEintraege.querySelector(
|
||||||
|
`.liste-weg[data-index="${String(Math.min(index, liste.length - 1))}"]`,
|
||||||
|
);
|
||||||
|
if (naechster instanceof HTMLElement) {
|
||||||
|
naechster.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listeEingabe.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Richtung des Schritts: der neue Barcode kommt von der Seite.
|
||||||
|
*
|
||||||
|
* @param {number} richtung
|
||||||
|
*/
|
||||||
|
function animiereSchritt(richtung) {
|
||||||
|
listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
|
||||||
|
// Reflow erzwingt den Neustart der Animation beim schnellen Blaettern.
|
||||||
|
void listeMitte.offsetWidth;
|
||||||
|
listeMitte.classList.add(richtung < 0 ? 'liste-schritt-zurueck' : 'liste-schritt-vor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} animiert
|
||||||
|
* @param {number} [richtung]
|
||||||
|
*/
|
||||||
|
function zeigeListenEintrag(animiert, richtung = 0) {
|
||||||
|
const eintrag = liste[listeIndex];
|
||||||
|
if (!eintrag) return;
|
||||||
|
listeZaehler.textContent = zaehlerText(listeIndex, liste.length);
|
||||||
|
listeGtin.textContent = eintrag.gtin;
|
||||||
|
listeArtikel.textContent = `Artikelnummer ${eintrag.eingabe}`;
|
||||||
|
rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
|
||||||
|
if (animiert) animiereSchritt(richtung);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} index */
|
||||||
|
function starteListenAnzeige(index) {
|
||||||
|
if (listeAnzeigeAktiv || liste.length === 0) return;
|
||||||
|
listeAnzeigeAktiv = true;
|
||||||
|
listeIndex = schritt(index, 0, liste.length);
|
||||||
|
listeErfassen.hidden = true;
|
||||||
|
listeAnzeige.hidden = false;
|
||||||
|
zeigeListenEintrag(false);
|
||||||
|
listeMitte.focus();
|
||||||
|
starteWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} richtung */
|
||||||
|
function blaettere(richtung) {
|
||||||
|
if (!listeAnzeigeAktiv || liste.length === 0) return;
|
||||||
|
listeIndex = schritt(listeIndex, richtung, liste.length);
|
||||||
|
zeigeListenEintrag(true, richtung);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} index */
|
||||||
|
function springeZu(index) {
|
||||||
|
if (!listeAnzeigeAktiv || liste.length === 0) return;
|
||||||
|
const neu = schritt(index, 0, liste.length);
|
||||||
|
if (neu === listeIndex) return;
|
||||||
|
const richtung = neu > listeIndex ? 1 : -1;
|
||||||
|
listeIndex = neu;
|
||||||
|
zeigeListenEintrag(true, richtung);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zurueck zum Erfassen: Die Liste bleibt erhalten und wird nur bearbeitet. */
|
||||||
|
function zeigeListeErfassen() {
|
||||||
|
listeAnzeigeAktiv = false;
|
||||||
|
listeAnzeige.hidden = true;
|
||||||
|
listeErfassen.hidden = false;
|
||||||
|
stoppeWakeLock();
|
||||||
|
versteckeListenFehler();
|
||||||
|
listeEingabe.value = '';
|
||||||
|
rendereListe();
|
||||||
|
listeEingabe.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function oeffneListe() {
|
||||||
|
if (listeAktiv) return;
|
||||||
|
listeAktiv = true;
|
||||||
|
listeBereich.hidden = false;
|
||||||
|
document.body.classList.add('liste-offen');
|
||||||
|
// Der Rest der Oberflaeche wird unerreichbar, solange die Liste offen ist.
|
||||||
|
appBereich.toggleAttribute('inert', true);
|
||||||
|
zeigeListeErfassen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseListe() {
|
||||||
|
if (!listeAktiv) return;
|
||||||
|
listeAktiv = false;
|
||||||
|
listeAnzeigeAktiv = false;
|
||||||
|
listeBereich.hidden = true;
|
||||||
|
listeErfassen.hidden = false;
|
||||||
|
listeAnzeige.hidden = true;
|
||||||
|
document.body.classList.remove('liste-offen');
|
||||||
|
appBereich.toggleAttribute('inert', false);
|
||||||
|
stoppeWakeLock();
|
||||||
|
listeOeffnenKnopf.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {{ id: number, x: number, y: number } | null} */
|
||||||
|
let geste = null;
|
||||||
|
|
||||||
|
/** @param {PointerEvent} ereignis */
|
||||||
|
function beendeGeste(ereignis) {
|
||||||
|
window.removeEventListener('pointerup', beendeGeste);
|
||||||
|
window.removeEventListener('pointercancel', beendeGeste);
|
||||||
|
const start = geste;
|
||||||
|
geste = null;
|
||||||
|
if (!start || start.id !== ereignis.pointerId || ereignis.type !== 'pointerup') return;
|
||||||
|
const dx = ereignis.clientX - start.x;
|
||||||
|
const dy = ereignis.clientY - start.y;
|
||||||
|
// Erst ab 40 px und nur bei klarer Richtung: sonst waere jeder Tipp ein Blatt.
|
||||||
|
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
|
blaettere(dx < 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} ereignis */
|
||||||
|
function starteGeste(ereignis) {
|
||||||
|
if (!listeAnzeigeAktiv || !ereignis.isPrimary) return;
|
||||||
|
// Ein Zug, der auf einem Pfeil beginnt, ist ein Klick auf den Pfeil.
|
||||||
|
if (ereignis.target instanceof Element && ereignis.target.closest('button')) return;
|
||||||
|
geste = { id: ereignis.pointerId, x: ereignis.clientX, y: ereignis.clientY };
|
||||||
|
window.addEventListener('pointerup', beendeGeste);
|
||||||
|
window.addEventListener('pointercancel', beendeGeste);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Zwischenablage -- */
|
||||||
|
/** @param {string} text @returns {boolean} */
|
||||||
|
function kopiereFallback(text) {
|
||||||
|
const hilfe = document.createElement('textarea');
|
||||||
|
hilfe.value = text;
|
||||||
|
hilfe.setAttribute('readonly', '');
|
||||||
|
hilfe.className = 'kopier-hilfe';
|
||||||
|
document.body.append(hilfe);
|
||||||
|
hilfe.select();
|
||||||
|
let erfolgreich = false;
|
||||||
|
try {
|
||||||
|
erfolgreich = document.execCommand('copy');
|
||||||
|
} catch {
|
||||||
|
erfolgreich = false;
|
||||||
|
}
|
||||||
|
hilfe.remove();
|
||||||
|
return erfolgreich;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kopiereGtin() {
|
||||||
|
if (!aktuell) return;
|
||||||
|
const text = aktuell.gtin;
|
||||||
|
let erfolgreich = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
erfolgreich = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
erfolgreich = false;
|
||||||
|
}
|
||||||
|
if (!erfolgreich) erfolgreich = kopiereFallback(text);
|
||||||
|
zeigeToast(erfolgreich ? 'GTIN kopiert' : 'Kopieren nicht möglich');
|
||||||
|
merkeImVerlauf(aktuell);
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} text */
|
||||||
|
function zeigeToast(text) {
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.hidden = false;
|
||||||
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toastTimer = undefined;
|
||||||
|
toast.hidden = true;
|
||||||
|
}, TOAST_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ PWA -- */
|
||||||
|
|
||||||
|
function registriereServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('./sw.js', { scope: './' })
|
||||||
|
.catch((fehler) => window.console.warn('Service Worker nicht registriert.', fehler));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktualisiereOfflineHinweis() {
|
||||||
|
offlineHinweis.hidden = navigator.onLine !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeInstallationsmoeglichkeit() {
|
||||||
|
const standalone =
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
/** @type {any} */ (navigator).standalone === true;
|
||||||
|
const istIos =
|
||||||
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
iosHinweis.hidden = !istIos || standalone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Ereignisse -- */
|
||||||
|
|
||||||
|
function verbindeEreignisse() {
|
||||||
|
input.addEventListener('input', beiEingabe);
|
||||||
|
// Fokussieren markiert den gesamten Inhalt (direktes Ueberschreiben), auch
|
||||||
|
// wenn der Browser das Caret nach dem Tippen selbst setzt.
|
||||||
|
input.addEventListener('pointerdown', () => {
|
||||||
|
feldWarFokussiert = document.activeElement === input;
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', markiereEingabe);
|
||||||
|
input.addEventListener('click', () => {
|
||||||
|
if (!feldWarFokussiert) markiereEingabe();
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
feldWarFokussiert = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
gtinKnopf.addEventListener('click', () => void kopiereGtin());
|
||||||
|
kopierenKnopf.addEventListener('click', () => void kopiereGtin());
|
||||||
|
grossKnopf.addEventListener('click', oeffneScan);
|
||||||
|
neuKnopf.addEventListener('click', neueNummer);
|
||||||
|
scanZurueckKnopf.addEventListener('click', schliesseScan);
|
||||||
|
verlaufLoeschenKnopf.addEventListener('click', leereVerlauf);
|
||||||
|
|
||||||
|
listeOeffnenKnopf.addEventListener('click', oeffneListe);
|
||||||
|
listeSchliessenKnopf.addEventListener('click', schliesseListe);
|
||||||
|
listeBeendenKnopf.addEventListener('click', schliesseListe);
|
||||||
|
listeAendernKnopf.addEventListener('click', zeigeListeErfassen);
|
||||||
|
listeWeiterKnopf.addEventListener('click', () => {
|
||||||
|
// Eine noch nicht bestaetigte Nummer wird beim Wechsel uebernommen.
|
||||||
|
if (listeEingabe.value.trim() !== '') {
|
||||||
|
uebernimmListeneingabe();
|
||||||
|
// Eine abgelehnte Nummer haelt die Erfassung offen: Der Fehler muss
|
||||||
|
// gelesen werden koennen, bevor es weitergeht.
|
||||||
|
if (!listeFehler.hidden) return;
|
||||||
|
}
|
||||||
|
// Ein leerer Aufruf mit alter Meldung im Feld raeumt sie weg und startet.
|
||||||
|
versteckeListenFehler();
|
||||||
|
starteListenAnzeige(0);
|
||||||
|
});
|
||||||
|
listeZurueckKnopf.addEventListener('click', () => blaettere(-1));
|
||||||
|
listeVorKnopf.addEventListener('click', () => blaettere(1));
|
||||||
|
listeBuehne.addEventListener('pointerdown', starteGeste);
|
||||||
|
listeMitte.addEventListener('animationend', () => {
|
||||||
|
listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
|
||||||
|
});
|
||||||
|
listeEingabe.addEventListener('keydown', (ereignis) => {
|
||||||
|
if (!istBestaetigungstaste(ereignis)) return;
|
||||||
|
// Selbst bestaetigen und die Standardaktion unterdruecken: Sonst kaeme
|
||||||
|
// dieselbe Nummer ueber Tastendruck und submit zweimal an.
|
||||||
|
ereignis.preventDefault();
|
||||||
|
uebernimmListeneingabe();
|
||||||
|
});
|
||||||
|
listeForm.addEventListener('submit', beiListenAbsenden);
|
||||||
|
// Die Schaltflaeche darf den Fokus nicht aus dem Feld ziehen: So bleibt die
|
||||||
|
// Bildschirmtastatur zwischen zwei Nummern offen.
|
||||||
|
listeHinzufuegenKnopf.addEventListener('pointerdown', (ereignis) => {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
});
|
||||||
|
listeEingabe.addEventListener('input', () => {
|
||||||
|
if (!listeFehler.hidden) versteckeListenFehler();
|
||||||
|
});
|
||||||
|
listeEintraege.addEventListener('click', (ereignis) => {
|
||||||
|
const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.liste-weg') : null;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
entferneListenEintrag(Number(ziel.dataset.index), ereignis.detail === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
verlaufListe.addEventListener('click', (ereignis) => {
|
||||||
|
const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.verlauf-eintrag') : null;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
const artikel = ziel.dataset.artikel ?? '';
|
||||||
|
if (artikel) {
|
||||||
|
// Mit der Tastatur aktiviert (Enter/Leertaste): Fokus ins Feld, damit
|
||||||
|
// direkt weitergetippt werden kann. Mit dem Finger bleibt der Fokus
|
||||||
|
// ausserhalb, sonst verdeckt die Bildschirmtastatur den Barcode.
|
||||||
|
uebernimmArtikel(artikel, { fokussieren: ereignis.detail === 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installierenKnopf.addEventListener('click', () => {
|
||||||
|
if (!installationsAnfrage) return;
|
||||||
|
installationsAnfrage.prompt();
|
||||||
|
Promise.resolve(installationsAnfrage.userChoice).catch(() => undefined);
|
||||||
|
installationsAnfrage = null;
|
||||||
|
installierenKnopf.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (ereignis) => {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
installationsAnfrage = ereignis;
|
||||||
|
installierenKnopf.hidden = false;
|
||||||
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
installationsAnfrage = null;
|
||||||
|
installierenKnopf.hidden = true;
|
||||||
|
iosHinweis.hidden = true;
|
||||||
|
zeigeToast('App installiert');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('online', aktualisiereOfflineHinweis);
|
||||||
|
window.addEventListener('offline', aktualisiereOfflineHinweis);
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && (scanAktiv || listeAnzeigeAktiv)) {
|
||||||
|
starteWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (groessenTimer !== undefined) window.clearTimeout(groessenTimer);
|
||||||
|
groessenTimer = window.setTimeout(() => {
|
||||||
|
groessenTimer = undefined;
|
||||||
|
if (aktuell) {
|
||||||
|
rendereBarcode(barcodeBehaelter, aktuell, 'normal');
|
||||||
|
if (scanAktiv) rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
|
||||||
|
}
|
||||||
|
if (listeAnzeigeAktiv) {
|
||||||
|
const eintrag = liste[listeIndex];
|
||||||
|
if (eintrag) rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (ereignis) => {
|
||||||
|
if (listeAktiv) {
|
||||||
|
if (ereignis.key === 'Escape') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
schliesseListe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!listeAnzeigeAktiv) return;
|
||||||
|
switch (ereignis.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'PageDown':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
blaettere(1);
|
||||||
|
return;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'PageUp':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
blaettere(-1);
|
||||||
|
return;
|
||||||
|
case 'Home':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
springeZu(0);
|
||||||
|
return;
|
||||||
|
case 'End':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
springeZu(liste.length - 1);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanAktiv) {
|
||||||
|
if (ereignis.key === 'Escape') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
schliesseScan();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ziel = ereignis.target;
|
||||||
|
const imEingabefeld = ziel === input;
|
||||||
|
// Auf Schaltflaechen und Links gilt die native Tastenaktivierung.
|
||||||
|
const aufBedienelement =
|
||||||
|
ziel instanceof HTMLElement &&
|
||||||
|
!imEingabefeld &&
|
||||||
|
(ziel.tagName === 'BUTTON' || ziel.tagName === 'A' || ziel.isContentEditable);
|
||||||
|
|
||||||
|
if (ereignis.key === 'Enter') {
|
||||||
|
if (imEingabefeld) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
if (!aktuell || aktuell.eingabe !== nurZiffern(input.value)) pruefeUndZeige();
|
||||||
|
if (aktuell) oeffneScan();
|
||||||
|
} else if (!aufBedienelement && aktuell) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
oeffneScan();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ereignis.key === 'Escape') {
|
||||||
|
if (!aufBedienelement && aktuell) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
neueNummer();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ereignis.key === 'Backspace' && !aufBedienelement && !imEingabefeld && aktuell) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
neueNummer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kopiertastenkombination =
|
||||||
|
(ereignis.ctrlKey || ereignis.metaKey) && ereignis.key.toLowerCase() === 'c';
|
||||||
|
if (kopiertastenkombination && aktuell) {
|
||||||
|
const auswahl = window.getSelection()?.toString() ?? '';
|
||||||
|
const eingabeAuswahl =
|
||||||
|
input.selectionStart !== null && input.selectionStart !== input.selectionEnd;
|
||||||
|
if (auswahl === '' && !eingabeAuswahl) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
void kopiereGtin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function starte() {
|
||||||
|
verbindeEreignisse();
|
||||||
|
rendereVerlauf();
|
||||||
|
aktualisiereOfflineHinweis();
|
||||||
|
zeigeInstallationsmoeglichkeit();
|
||||||
|
registriereServiceWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
starte();
|
||||||
343
site/gtin/js/barcode.js
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
/**
|
||||||
|
* EAN-Codierung und SVG-Erzeugung.
|
||||||
|
*
|
||||||
|
* Die Datei ist bewusst frei von DOM-Zugriffen: sie liefert eine reine
|
||||||
|
* Bitfolge (1 = Balken, 0 = Luecke) und daraus ein SVG-Markup. Die
|
||||||
|
* Pruefzifferpruefung aus gtin.js wird vor jeder Codierung wiederholt, damit
|
||||||
|
* kein Barcode fuer eine inkonsistente Nummer entstehen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { gtinGueltig } from './gtin.js';
|
||||||
|
|
||||||
|
/** L-Code (ungerade Paritaet), linke Haelfte. */
|
||||||
|
const L_CODE = [
|
||||||
|
'0001101',
|
||||||
|
'0011001',
|
||||||
|
'0010011',
|
||||||
|
'0111101',
|
||||||
|
'0100011',
|
||||||
|
'0110001',
|
||||||
|
'0101111',
|
||||||
|
'0111011',
|
||||||
|
'0110111',
|
||||||
|
'0001011',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** G-Code (gerade Paritaet), linke Haelfte bei EAN-13. */
|
||||||
|
const G_CODE = [
|
||||||
|
'0100111',
|
||||||
|
'0110011',
|
||||||
|
'0011011',
|
||||||
|
'0100001',
|
||||||
|
'0011101',
|
||||||
|
'0111001',
|
||||||
|
'0000101',
|
||||||
|
'0010001',
|
||||||
|
'0001001',
|
||||||
|
'0010111',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** R-Code, rechte Haelfte (Komplement des L-Codes). */
|
||||||
|
const R_CODE = [
|
||||||
|
'1110010',
|
||||||
|
'1100110',
|
||||||
|
'1101100',
|
||||||
|
'1000010',
|
||||||
|
'1011100',
|
||||||
|
'1001110',
|
||||||
|
'1010000',
|
||||||
|
'1000100',
|
||||||
|
'1001000',
|
||||||
|
'1110100',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paritaetsmuster der linken Gruppe in Abhaengigkeit der ersten Ziffer
|
||||||
|
* (EAN-13). Die erste Ziffer wird selbst nicht als Balken codiert.
|
||||||
|
*/
|
||||||
|
const EAN13_PARITAET = [
|
||||||
|
'LLLLLL',
|
||||||
|
'LLGLGG',
|
||||||
|
'LLGGLG',
|
||||||
|
'LLGGGL',
|
||||||
|
'LGLLGG',
|
||||||
|
'LGGLLG',
|
||||||
|
'LGGGLL',
|
||||||
|
'LGLGLG',
|
||||||
|
'LGLGGL',
|
||||||
|
'LGGLGL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const START_UND_ENDE = '101';
|
||||||
|
const MITTE = '01010';
|
||||||
|
|
||||||
|
/** Ruhezonen in Modulen (links/rechts). */
|
||||||
|
export const QUIETZONE = {
|
||||||
|
'EAN-8': { links: 7, rechts: 7 },
|
||||||
|
'EAN-13': { links: 11, rechts: 7 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Standardhoehe der Balken in Modulen. */
|
||||||
|
export const BALKENHOEHE_MODULE = 62;
|
||||||
|
|
||||||
|
/** Verlaengerung der Rand- und Mittelbalken nach unten, in Modulen. */
|
||||||
|
export const GUARD_ZUSATZ_MODULE = 6;
|
||||||
|
|
||||||
|
/** Reservierte Hoehe fuer die Klartextzeile, in Modulen. */
|
||||||
|
export const TEXT_HOEHE_MODULE = 13;
|
||||||
|
|
||||||
|
/** Schriftgroesse des Klartexts, in Modulen. */
|
||||||
|
export const TEXT_GROESSE_MODULE = 9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Textgruppe
|
||||||
|
* @property {string} text anzuzeigende Ziffern
|
||||||
|
* @property {number} start Startposition im Symbol (Modulindex)
|
||||||
|
* @property {number} breite Breite der Gruppe in Modulen
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} EanSymbol
|
||||||
|
* @property {string} gtin validierte GTIN
|
||||||
|
* @property {'EAN-8' | 'EAN-13'} typ Barcode-Typ
|
||||||
|
* @property {string} bits Bitfolge (1 = Balken)
|
||||||
|
* @property {number} module Anzahl der Symbolmodule ohne Ruhezonen
|
||||||
|
* @property {{ links: number, rechts: number }} quietzone Ruhezonen in Modulen
|
||||||
|
* @property {Textgruppe[]} textgruppen Klartext unter dem Symbol
|
||||||
|
* @property {string | null} fuehrendeZiffer erste Ziffer (nur EAN-13)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codiert eine vollstaendige GTIN als EAN-8 oder EAN-13.
|
||||||
|
*
|
||||||
|
* @param {unknown} gtin vollstaendige GTIN (8 oder 13 Ziffern)
|
||||||
|
* @returns {EanSymbol}
|
||||||
|
* @throws {Error} wenn die GTIN ungueltig ist oder die Codierung nicht aufgeht
|
||||||
|
*/
|
||||||
|
export function kodiereEan(gtin) {
|
||||||
|
const wert = String(gtin ?? '');
|
||||||
|
if (!gtinGueltig(wert)) {
|
||||||
|
throw new Error(`Ungültige GTIN: ${wert}`);
|
||||||
|
}
|
||||||
|
const typ = wert.length === 8 ? 'EAN-8' : 'EAN-13';
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
|
let links;
|
||||||
|
/** @type {string} */
|
||||||
|
let rechts;
|
||||||
|
/** @type {Textgruppe[]} */
|
||||||
|
let textgruppen;
|
||||||
|
let fuehrendeZiffer = null;
|
||||||
|
|
||||||
|
if (typ === 'EAN-8') {
|
||||||
|
links = '';
|
||||||
|
for (let i = 0; i < 4; i += 1) links += L_CODE[Number(wert[i])];
|
||||||
|
rechts = '';
|
||||||
|
for (let i = 4; i < 8; i += 1) rechts += R_CODE[Number(wert[i])];
|
||||||
|
textgruppen = [
|
||||||
|
{ text: wert.slice(0, 4), start: 3, breite: 28 },
|
||||||
|
{ text: wert.slice(4, 8), start: 36, breite: 28 },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const paritaet = EAN13_PARITAET[Number(wert[0])];
|
||||||
|
links = '';
|
||||||
|
for (let i = 1; i <= 6; i += 1) {
|
||||||
|
const ziffer = Number(wert[i]);
|
||||||
|
links += paritaet[i - 1] === 'L' ? L_CODE[ziffer] : G_CODE[ziffer];
|
||||||
|
}
|
||||||
|
rechts = '';
|
||||||
|
for (let i = 7; i < 13; i += 1) rechts += R_CODE[Number(wert[i])];
|
||||||
|
textgruppen = [
|
||||||
|
{ text: wert.slice(1, 7), start: 3, breite: 42 },
|
||||||
|
{ text: wert.slice(7, 13), start: 50, breite: 42 },
|
||||||
|
];
|
||||||
|
fuehrendeZiffer = wert[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bits = START_UND_ENDE + links + MITTE + rechts + START_UND_ENDE;
|
||||||
|
const erwartet = typ === 'EAN-8' ? 67 : 95;
|
||||||
|
if (bits.length !== erwartet || !/^[01]+$/.test(bits)) {
|
||||||
|
throw new Error(`EAN-Codierung fehlerhaft: ${bits.length} statt ${erwartet} Module.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gtin: wert,
|
||||||
|
typ,
|
||||||
|
bits,
|
||||||
|
module: bits.length,
|
||||||
|
quietzone: QUIETZONE[typ],
|
||||||
|
textgruppen,
|
||||||
|
fuehrendeZiffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groesste ganzzahlige Modulbreite, die in die verfuegbare Breite passt.
|
||||||
|
*
|
||||||
|
* @param {number} verfuegbareBreitePx
|
||||||
|
* @param {number} moduleGesamt Anzahl Module inklusive Ruhezonen
|
||||||
|
* @returns {number} ganzzahlige Modulbreite in Pixeln, mindestens 1
|
||||||
|
*/
|
||||||
|
export function modulbreite(verfuegbareBreitePx, moduleGesamt) {
|
||||||
|
if (!Number.isFinite(verfuegbareBreitePx) || moduleGesamt <= 0) return 1;
|
||||||
|
return Math.max(1, Math.floor(verfuegbareBreitePx / moduleGesamt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximale Balkenhoehe (in Modulen) fuer eine verfuegbare Anzeigehoehe.
|
||||||
|
*
|
||||||
|
* @param {number} verfuegbareHoehePx
|
||||||
|
* @param {number} modulbreitePx
|
||||||
|
* @param {number} [minimum] kleinste sinnvolle Balkenhoehe in Modulen
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function balkenhoeheFuerHoehe(verfuegbareHoehePx, modulbreitePx, minimum = 30) {
|
||||||
|
if (!Number.isFinite(verfuegbareHoehePx) || verfuegbareHoehePx <= 0 || modulbreitePx <= 0) {
|
||||||
|
return BALKENHOEHE_MODULE;
|
||||||
|
}
|
||||||
|
const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||||
|
const moeglich = Math.floor(verfuegbareHoehePx / modulbreitePx) - reserviert;
|
||||||
|
return Math.max(minimum, moeglich);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleinste sinnvolle Balkenhoehe in Modulen. */
|
||||||
|
export const MIN_BALKENHOEHE_MODULE = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waehlt Modulbreite und Balkenhoehe fuer eine verfuegbare Flaeche.
|
||||||
|
*
|
||||||
|
* Gesucht ist die groesste ganzzahlige Modulbreite, mit der der komplette
|
||||||
|
* Barcode (Balken, Guard-Zusatz und Klartextzeile) sowohl in die Breite als
|
||||||
|
* auch in die Hoehe passt. So fuellt der Barcode die Anzeige bestmoeglich aus,
|
||||||
|
* ohne abgeschnitten zu werden; die Modulbreite bleibt ganzzahlig und damit
|
||||||
|
* gestochen scharf.
|
||||||
|
*
|
||||||
|
* @param {number} verfuegbareBreitePx
|
||||||
|
* @param {number} verfuegbareHoehePx
|
||||||
|
* @param {number} moduleGesamt Anzahl der Module inklusive Ruhezonen
|
||||||
|
* @param {{ minimumBalkenhoehe?: number, maximumBalkenhoehe?: number }} [optionen]
|
||||||
|
* @returns {{ modulbreite: number, balkenhoeheModule: number }}
|
||||||
|
*/
|
||||||
|
export function barcodeGroesse(verfuegbareBreitePx, verfuegbareHoehePx, moduleGesamt, optionen = {}) {
|
||||||
|
const minimum = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(optionen.minimumBalkenhoehe ?? MIN_BALKENHOEHE_MODULE),
|
||||||
|
);
|
||||||
|
const maximum = Math.max(
|
||||||
|
minimum,
|
||||||
|
Math.round(optionen.maximumBalkenhoehe ?? BALKENHOEHE_MODULE),
|
||||||
|
);
|
||||||
|
const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||||
|
const breite =
|
||||||
|
Number.isFinite(verfuegbareBreitePx) && verfuegbareBreitePx > 0
|
||||||
|
? Math.floor(verfuegbareBreitePx)
|
||||||
|
: 320;
|
||||||
|
const hoehe =
|
||||||
|
Number.isFinite(verfuegbareHoehePx) && verfuegbareHoehePx > 0
|
||||||
|
? Math.floor(verfuegbareHoehePx)
|
||||||
|
: 320;
|
||||||
|
const module = Number.isFinite(moduleGesamt) && moduleGesamt > 0 ? Math.floor(moduleGesamt) : 1;
|
||||||
|
|
||||||
|
const groessteBreite = Math.max(1, Math.floor(breite / module));
|
||||||
|
for (let kandidat = groessteBreite; kandidat >= 1; kandidat -= 1) {
|
||||||
|
const platz = Math.floor(hoehe / kandidat) - reserviert;
|
||||||
|
if (kandidat === 1 || platz >= minimum) {
|
||||||
|
return {
|
||||||
|
modulbreite: kandidat,
|
||||||
|
balkenhoeheModule: Math.min(maximum, Math.max(minimum, platz)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { modulbreite: 1, balkenhoeheModule: minimum };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt die Bitfolge in zusammenhaengende Balken.
|
||||||
|
*
|
||||||
|
* @param {string} bits
|
||||||
|
* @returns {{ start: number, breite: number }[]}
|
||||||
|
*/
|
||||||
|
function balkenLaeufe(bits) {
|
||||||
|
/** @type {{ start: number, breite: number }[]} */
|
||||||
|
const laeufe = [];
|
||||||
|
let start = -1;
|
||||||
|
for (let i = 0; i < bits.length; i += 1) {
|
||||||
|
if (bits[i] === '1') {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (start !== -1) {
|
||||||
|
laeufe.push({ start, breite: i - start });
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start !== -1) laeufe.push({ start, breite: bits.length - start });
|
||||||
|
return laeufe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt das SVG-Markup fuer einen EAN-Barcode.
|
||||||
|
*
|
||||||
|
* Alle Laengen werden in ganzen Modulen gerechnet; die Pixelbreite ergibt sich
|
||||||
|
* aus einem ganzzahligen Vielfachen der Modulbreite. Dadurch entstehen harte
|
||||||
|
* Kanten ohne Teilpixel.
|
||||||
|
*
|
||||||
|
* @param {unknown} gtin vollstaendige GTIN
|
||||||
|
* @param {{ modulbreite?: number, balkenhoeheModule?: number }} [optionen]
|
||||||
|
* @returns {{ markup: string, breite: number, hoehe: number, modulbreite: number, symbol: EanSymbol }}
|
||||||
|
*/
|
||||||
|
export function erzeugeSvgMarkup(gtin, optionen = {}) {
|
||||||
|
const symbol = kodiereEan(gtin);
|
||||||
|
const modulbreitePx = Math.max(1, Math.round(optionen.modulbreite ?? 3));
|
||||||
|
const balkenhoehe = Math.max(
|
||||||
|
20,
|
||||||
|
Math.round(optionen.balkenhoeheModule ?? BALKENHOEHE_MODULE),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { links, rechts } = symbol.quietzone;
|
||||||
|
const gesamtModule = links + symbol.module + rechts;
|
||||||
|
const hoeheModule = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||||
|
const breitePx = gesamtModule * modulbreitePx;
|
||||||
|
const hoehePx = hoeheModule * modulbreitePx;
|
||||||
|
|
||||||
|
const linkeGruppeEnde = 3 + (symbol.typ === 'EAN-8' ? 28 : 42);
|
||||||
|
const guardBereiche = [
|
||||||
|
{ start: 0, ende: 3 },
|
||||||
|
{ start: linkeGruppeEnde, ende: linkeGruppeEnde + 5 },
|
||||||
|
{ start: symbol.module - 3, ende: symbol.module },
|
||||||
|
];
|
||||||
|
const istGuard = (/** @type {number} */ start, /** @type {number} */ breite) =>
|
||||||
|
guardBereiche.some((bereich) => start >= bereich.start && start + breite <= bereich.ende);
|
||||||
|
|
||||||
|
const rechtecke = balkenLaeufe(symbol.bits)
|
||||||
|
.map(({ start, breite }) => {
|
||||||
|
const hoehe = balkenhoehe + (istGuard(start, breite) ? GUARD_ZUSATZ_MODULE : 0);
|
||||||
|
return `<rect x="${links + start}" y="0" width="${breite}" height="${hoehe}" fill="#000000"/>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
|
||||||
|
const text = symbol.textgruppen
|
||||||
|
.map(
|
||||||
|
(gruppe) =>
|
||||||
|
`<text x="${links + gruppe.start + gruppe.breite / 2}" y="${grundlinie}" ` +
|
||||||
|
`font-size="${TEXT_GROESSE_MODULE}" text-anchor="middle" ` +
|
||||||
|
`textLength="${gruppe.breite}" lengthAdjust="spacing" fill="#000000">${gruppe.text}</text>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const fuehrend = symbol.fuehrendeZiffer
|
||||||
|
? `<text x="${links - 4}" y="${grundlinie}" font-size="${TEXT_GROESSE_MODULE}" ` +
|
||||||
|
`text-anchor="middle" fill="#000000">${symbol.fuehrendeZiffer}</text>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
|
||||||
|
const markup =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${breitePx}" height="${hoehePx}" ` +
|
||||||
|
`viewBox="0 0 ${gesamtModule} ${hoeheModule}" preserveAspectRatio="xMidYMid meet" ` +
|
||||||
|
`role="img" aria-label="${beschriftung}" focusable="false">` +
|
||||||
|
`<title>${beschriftung}</title>` +
|
||||||
|
`<rect x="0" y="0" width="${gesamtModule}" height="${hoeheModule}" fill="#ffffff"/>` +
|
||||||
|
`<g shape-rendering="crispEdges">${rechtecke}</g>` +
|
||||||
|
`<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">${fuehrend}${text}</g>` +
|
||||||
|
`</svg>`;
|
||||||
|
|
||||||
|
return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
|
||||||
|
}
|
||||||
162
site/gtin/js/gtin.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Fachliche Logik des GTIN-Generators.
|
||||||
|
*
|
||||||
|
* Diese Datei enthaelt ausschliesslich die Nummernkreis-Regeln, die
|
||||||
|
* Pruefziffernberechnung und die Validierung. Sie kennt weder DOM noch
|
||||||
|
* Oberflaeche und ist deshalb direkt testbar.
|
||||||
|
*
|
||||||
|
* Regeln (belegte Formate):
|
||||||
|
* 3 Ziffern -> links mit 0 auf 4 Stellen auffuellen -> Praefix 290 -> EAN-8
|
||||||
|
* 4 Ziffern -> Praefix 290 -> EAN-8
|
||||||
|
* 7 Ziffern -> Praefix 20090 -> EAN-13
|
||||||
|
* alles andere ist ungueltig.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {'EAN-8' | 'EAN-13'} GtinTyp */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} GtinErgebnis
|
||||||
|
* @property {string} eingabe normalisierte Artikelnummer (3, 4 oder 7 Stellen)
|
||||||
|
* @property {string} artikelblock Artikelblock mit fuehrender Null (4 Stellen)
|
||||||
|
* @property {string} praefix Nummernkreis-Praefix
|
||||||
|
* @property {string} basis GTIN ohne Pruefziffer
|
||||||
|
* @property {string} pruefziffer berechnete Pruefziffer
|
||||||
|
* @property {string} gtin vollstaendige GTIN inklusive Pruefziffer
|
||||||
|
* @property {GtinTyp} typ Barcode-Typ
|
||||||
|
* @property {number} laenge GTIN-Laenge (8 oder 13)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fehlertext fuer die Oberflaeche (bewusst genau ein Text). */
|
||||||
|
export const FEHLERTEXT =
|
||||||
|
'Keine gültige Artikelnummer. Erwartet werden 3, 4 oder 7 Stellen.';
|
||||||
|
|
||||||
|
/** Praefix des kurzen Nummernkreises (EAN-8). */
|
||||||
|
export const PRAEFIX_KURZ = '290';
|
||||||
|
|
||||||
|
/** Praefix des langen Nummernkreises (EAN-13). */
|
||||||
|
export const PRAEFIX_LANG = '20090';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt alles, was keine Ziffer ist. Leere Eingaben werden zu "".
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function nurZiffern(eingabe) {
|
||||||
|
return String(eingabe ?? '').replace(/[^0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet die GTIN-Pruefziffer fuer eine Ziffernfolge ohne Pruefziffer.
|
||||||
|
*
|
||||||
|
* Die Ziffern werden von rechts nach links abwechselnd mit 3 und 1 gewichtet
|
||||||
|
* (rechteste Ziffer mal 3). Danach gilt (10 - (Summe mod 10)) mod 10.
|
||||||
|
*
|
||||||
|
* @param {string} ziffern Ziffernfolge ohne Pruefziffer, mindestens eine Ziffer
|
||||||
|
* @returns {number} Pruefziffer 0-9
|
||||||
|
*/
|
||||||
|
export function berechnePruefziffer(ziffern) {
|
||||||
|
if (typeof ziffern !== 'string' || !/^[0-9]+$/.test(ziffern)) {
|
||||||
|
throw new Error('Prüfziffer kann nur für eine Ziffernfolge berechnet werden.');
|
||||||
|
}
|
||||||
|
let summe = 0;
|
||||||
|
for (let i = 0; i < ziffern.length; i += 1) {
|
||||||
|
const ziffer = Number(ziffern[ziffern.length - 1 - i]);
|
||||||
|
summe += ziffer * (i % 2 === 0 ? 3 : 1);
|
||||||
|
}
|
||||||
|
return (10 - (summe % 10)) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft eine vollstaendige GTIN als komplette EAN-Nummer.
|
||||||
|
*
|
||||||
|
* @param {unknown} gtin
|
||||||
|
* @returns {boolean} true, wenn Laenge, Ziffern und Pruefziffer stimmen
|
||||||
|
*/
|
||||||
|
export function gtinGueltig(gtin) {
|
||||||
|
const wert = String(gtin ?? '');
|
||||||
|
if (!/^(?:[0-9]{8}|[0-9]{13})$/.test(wert)) return false;
|
||||||
|
const basis = wert.slice(0, -1);
|
||||||
|
return berechnePruefziffer(basis) === Number(wert.slice(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt den Nummernkreis einer normalisierten Artikelnummer.
|
||||||
|
*
|
||||||
|
* @param {string} ziffern ausschliesslich Ziffern
|
||||||
|
* @returns {{ praefix: string, artikelblock: string, typ: GtinTyp, laenge: number } | null}
|
||||||
|
*/
|
||||||
|
export function bestimmeNummernkreis(ziffern) {
|
||||||
|
if (!/^[0-9]+$/.test(ziffern)) return null;
|
||||||
|
if (ziffern.length === 3) {
|
||||||
|
return {
|
||||||
|
praefix: PRAEFIX_KURZ,
|
||||||
|
artikelblock: ziffern.padStart(4, '0'),
|
||||||
|
typ: 'EAN-8',
|
||||||
|
laenge: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ziffern.length === 4) {
|
||||||
|
return {
|
||||||
|
praefix: PRAEFIX_KURZ,
|
||||||
|
artikelblock: ziffern,
|
||||||
|
typ: 'EAN-8',
|
||||||
|
laenge: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ziffern.length === 7) {
|
||||||
|
return {
|
||||||
|
praefix: PRAEFIX_LANG,
|
||||||
|
artikelblock: ziffern,
|
||||||
|
typ: 'EAN-13',
|
||||||
|
laenge: 13,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand einer Eingabe fuer die Oberflaeche.
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe
|
||||||
|
* @returns {'leer' | 'unvollstaendig' | 'ungueltig' | 'ok'}
|
||||||
|
*/
|
||||||
|
export function eingabeStatus(eingabe) {
|
||||||
|
const ziffern = nurZiffern(eingabe);
|
||||||
|
if (ziffern.length === 0) return 'leer';
|
||||||
|
if (ziffern.length < 3) return 'unvollstaendig';
|
||||||
|
return bestimmeNummernkreis(ziffern) ? 'ok' : 'ungueltig';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt aus einer Artikelnummer die vollstaendige GTIN.
|
||||||
|
*
|
||||||
|
* Die erzeugte GTIN wird anschliessend erneut als komplette EAN validiert
|
||||||
|
* (Sicherheitspruefung). Stimmt etwas nicht, wird null zurueckgegeben und es
|
||||||
|
* darf kein Barcode gerendert werden.
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe Artikelnummer (3, 4 oder 7 Ziffern, Leerzeichen erlaubt)
|
||||||
|
* @returns {GtinErgebnis | null}
|
||||||
|
*/
|
||||||
|
export function erzeugeGtin(eingabe) {
|
||||||
|
const ziffern = nurZiffern(eingabe);
|
||||||
|
const kreis = bestimmeNummernkreis(ziffern);
|
||||||
|
if (!kreis) return null;
|
||||||
|
|
||||||
|
const basis = kreis.praefix + kreis.artikelblock;
|
||||||
|
const pruefziffer = berechnePruefziffer(basis);
|
||||||
|
const gtin = `${basis}${pruefziffer}`;
|
||||||
|
|
||||||
|
if (gtin.length !== kreis.laenge || !gtinGueltig(gtin)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eingabe: ziffern,
|
||||||
|
artikelblock: kreis.artikelblock,
|
||||||
|
praefix: kreis.praefix,
|
||||||
|
basis,
|
||||||
|
pruefziffer: String(pruefziffer),
|
||||||
|
gtin,
|
||||||
|
typ: kreis.typ,
|
||||||
|
laenge: kreis.laenge,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
site/gtin/js/liste.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Reine Listenlogik des optionalen Listenmodus.
|
||||||
|
*
|
||||||
|
* Die Datei kennt weder DOM noch Oberflaeche: Sie zerlegt eine Eingabe in
|
||||||
|
* Artikelnummern und rechnet die Schritte der Anzeige aus. Damit ist die Logik
|
||||||
|
* unabhaengig von der Oberflaeche testbar, genau wie gtin.js und barcode.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt eine Eingabe in einzelne Zifferngruppen.
|
||||||
|
*
|
||||||
|
* Alles, was keine Ziffer ist, trennt die Gruppen: Leerzeichen, Komma,
|
||||||
|
* Semikolon, Zeilenumbruch und auch Buchstaben. So laesst sich eine ganze
|
||||||
|
* Spalte aus einem Lieferschein in einem Zug einfuegen, ohne dass daraus eine
|
||||||
|
* einzige, viel zu lange Nummer wird.
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe
|
||||||
|
* @returns {string[]} Zifferngruppen in Eingabereihenfolge, ohne leere Gruppen
|
||||||
|
*/
|
||||||
|
export function zerlegeEingabe(eingabe) {
|
||||||
|
return String(eingabe ?? '')
|
||||||
|
.split(/[^0-9]+/)
|
||||||
|
.filter((gruppe) => gruppe.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naechster Index in der Liste.
|
||||||
|
*
|
||||||
|
* Der Schritt laeuft um: Nach dem letzten Eintrag folgt wieder der erste und
|
||||||
|
* umgekehrt. Damit laesst sich eine Serie ohne Unterbrechung nacheinander
|
||||||
|
* abscannen.
|
||||||
|
*
|
||||||
|
* @param {number} index aktueller Index
|
||||||
|
* @param {number} richtung Schrittweite, z. B. 1 oder -1
|
||||||
|
* @param {number} anzahl Anzahl der Eintraege
|
||||||
|
* @returns {number} Index innerhalb von 0 bis anzahl-1
|
||||||
|
*/
|
||||||
|
export function schritt(index, richtung, anzahl) {
|
||||||
|
if (!Number.isInteger(anzahl) || anzahl <= 0) return 0;
|
||||||
|
const start = Number.isInteger(index) ? index : 0;
|
||||||
|
const schrittweite = Number.isInteger(richtung) ? richtung : 0;
|
||||||
|
return (((start + schrittweite) % anzahl) + anzahl) % anzahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beschriftung des Zaehlers, z. B. "3 / 7".
|
||||||
|
*
|
||||||
|
* @param {number} index aktueller Index
|
||||||
|
* @param {number} anzahl Anzahl der Eintraege
|
||||||
|
* @returns {string} leer, wenn die Liste leer ist
|
||||||
|
*/
|
||||||
|
export function zaehlerText(index, anzahl) {
|
||||||
|
if (!Number.isInteger(anzahl) || anzahl <= 0) return '';
|
||||||
|
const position = (Number.isInteger(index) ? index : 0) + 1;
|
||||||
|
return `${Math.min(Math.max(position, 1), anzahl)} / ${anzahl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt die Bestaetigungstaste einer Tastatur.
|
||||||
|
*
|
||||||
|
* Ein Numpad auf dem Telefon hat keine Eingabetaste: Es bestaetigt mit einem
|
||||||
|
* eigenen Schluessel (Haekchen, "Fertig", "Los"). Je nach Tastatur meldet der
|
||||||
|
* Browser ihn als `key: "Enter"` oder nur ueber den alten `keyCode` 13, in
|
||||||
|
* manchen Faellen mit `key: "Unidentified"`. Beide Kennungen muessen gelten,
|
||||||
|
* sonst bleibt die eingegebene Nummer auf dem Telefon liegen.
|
||||||
|
*
|
||||||
|
* @param {{ key?: unknown, keyCode?: unknown } | null | undefined} ereignis Tastenereignis
|
||||||
|
* @returns {boolean} true, wenn die Taste die Eingabe bestaetigt
|
||||||
|
*/
|
||||||
|
export function istBestaetigungstaste(ereignis) {
|
||||||
|
if (!ereignis) return false;
|
||||||
|
const taste = typeof ereignis.key === 'string' ? ereignis.key : '';
|
||||||
|
const kennung = typeof ereignis.keyCode === 'number' ? ereignis.keyCode : 0;
|
||||||
|
return taste === 'Enter' || kennung === 13;
|
||||||
|
}
|
||||||
34
site/gtin/manifest.webmanifest
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "GTIN Generator",
|
||||||
|
"short_name": "GTIN",
|
||||||
|
"description": "Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen – offline, ohne Anmeldung.",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"categories": ["business", "productivity", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
173
site/gtin/sw.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/* Service Worker des GTIN-Generators.
|
||||||
|
*
|
||||||
|
* Nach dem ersten Laden liegen HTML, CSS, JavaScript, Manifest und Icons im
|
||||||
|
* Cache. Danach funktioniert die Anwendung ohne WLAN, ohne Mobilfunk und ohne
|
||||||
|
* erreichbaren Server.
|
||||||
|
*
|
||||||
|
* CACHE_VERSION wird beim Build aus dem Inhalt der Anwendungsdateien ersetzt.
|
||||||
|
* Dadurch erkennt der Browser ein neues Release und raeumt den alten Cache auf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (self));
|
||||||
|
|
||||||
|
const CACHE_VERSION = '15d23ab0263e';
|
||||||
|
const CACHE_PREFIX = 'desfoto-gtin-generator-';
|
||||||
|
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// PRECACHE
|
||||||
|
const PRECACHE = [
|
||||||
|
'./',
|
||||||
|
'./index.html',
|
||||||
|
'./css/app.css',
|
||||||
|
'./js/app.js',
|
||||||
|
'./js/gtin.js',
|
||||||
|
'./js/barcode.js',
|
||||||
|
'./js/liste.js',
|
||||||
|
'./manifest.webmanifest',
|
||||||
|
'./icons/favicon.svg',
|
||||||
|
'./icons/icon-192.png',
|
||||||
|
'./icons/icon-512.png',
|
||||||
|
'./icons/icon-maskable-512.png',
|
||||||
|
];
|
||||||
|
// END PRECACHE
|
||||||
|
|
||||||
|
/** @param {string} pfad */
|
||||||
|
function scopeUrl(pfad) {
|
||||||
|
return new URL(pfad, sw.registration.scope).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDEX_URL = scopeUrl('./index.html');
|
||||||
|
|
||||||
|
/** Pfad des Geltungsbereichs, z. B. "/gtin/". */
|
||||||
|
const SCOPE_PFAD = new URL(sw.registration.scope).pathname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennung der ausgelieferten Anwendungsquelle. Nur eine Antwort, die diese
|
||||||
|
* Kennung enthaelt, ist die echte index.html dieses Projekts.
|
||||||
|
*/
|
||||||
|
const SHELL_KENNUNG = 'id="artikelnummer"';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entscheidet, ob eine Antwort die vorgehaltene Anwendungshuelle ersetzen darf.
|
||||||
|
*
|
||||||
|
* Nur eine erfolgreiche HTML-Antwort der eigenen Anwendung ist zulaessig. Eine
|
||||||
|
* Anmeldeseite der Steuerungsebene, eine Magic-Link-Seite oder eine Fehlerseite
|
||||||
|
* (z. B. "App deployment in progress") wuerde die Anwendung sonst offline
|
||||||
|
* unbrauchbar machen, weil sie die echte Seite aus dem Cache verdraengt.
|
||||||
|
*
|
||||||
|
* @param {Response} antwort
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function istAnwendungshuelle(antwort) {
|
||||||
|
if (!antwort.ok || antwort.type !== 'basic') return false;
|
||||||
|
const inhaltstyp = antwort.headers.get('content-type') ?? '';
|
||||||
|
if (!inhaltstyp.includes('text/html')) return false;
|
||||||
|
const endgueltig = new URL(antwort.url);
|
||||||
|
if (endgueltig.origin !== sw.location.origin) return false;
|
||||||
|
if (!endgueltig.pathname.startsWith(SCOPE_PFAD)) return false;
|
||||||
|
try {
|
||||||
|
return (await antwort.text()).includes(SHELL_KENNUNG);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.addEventListener('install', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) =>
|
||||||
|
cache.addAll(
|
||||||
|
PRECACHE.map((pfad) => new Request(scopeUrl(pfad), { cache: 'reload' })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => sw.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((namen) =>
|
||||||
|
Promise.all(
|
||||||
|
namen
|
||||||
|
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
|
||||||
|
.map((name) => caches.delete(name)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => sw.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bedient einen Navigationsaufruf.
|
||||||
|
*
|
||||||
|
* Bewusst aus dem Cache: Nur so stammen HTML und Assets immer aus derselben
|
||||||
|
* Cache-Generation. Der neue Service Worker uebernimmt dank
|
||||||
|
* skipWaiting()/clients.claim() sofort; ein neues Release ist damit beim
|
||||||
|
* naechsten Laden konsistent aktiv (kein "neues HTML, altes JavaScript").
|
||||||
|
*
|
||||||
|
* @param {FetchEvent} ereignis
|
||||||
|
* @param {Request} anfrage
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
function bedieneNavigation(ereignis, anfrage) {
|
||||||
|
return caches.match(INDEX_URL).then((treffer) => {
|
||||||
|
if (treffer) return treffer;
|
||||||
|
return fetch(anfrage)
|
||||||
|
.then((antwort) => {
|
||||||
|
const pruefung = antwort.clone();
|
||||||
|
const kopie = antwort.clone();
|
||||||
|
// waitUntil haelt die Cache-Schreiboperation am Leben. Ein blosses
|
||||||
|
// "fire and forget" innerhalb von respondWith kann abgebrochen werden.
|
||||||
|
ereignis.waitUntil(
|
||||||
|
istAnwendungshuelle(pruefung)
|
||||||
|
.then((istHuelle) =>
|
||||||
|
istHuelle
|
||||||
|
? caches.open(CACHE_NAME).then((cache) => cache.put(INDEX_URL, kopie))
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
.catch(() => undefined),
|
||||||
|
);
|
||||||
|
return antwort;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(INDEX_URL).then((treffer) => treffer ?? Response.error()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (ereignis) => {
|
||||||
|
const anfrage = ereignis.request;
|
||||||
|
if (anfrage.method !== 'GET') return;
|
||||||
|
|
||||||
|
const ziel = new URL(anfrage.url);
|
||||||
|
if (ziel.origin !== sw.location.origin) return;
|
||||||
|
if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;
|
||||||
|
if (ziel.pathname.endsWith('/health')) return;
|
||||||
|
|
||||||
|
if (anfrage.mode === 'navigate') {
|
||||||
|
const appStart = new URL('./', sw.registration.scope).pathname;
|
||||||
|
const appIndex = new URL('./index.html', sw.registration.scope).pathname;
|
||||||
|
if (ziel.pathname !== appStart && ziel.pathname !== appIndex) return;
|
||||||
|
ereignis.respondWith(bedieneNavigation(ereignis, anfrage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ereignis.respondWith(
|
||||||
|
caches.match(anfrage).then((treffer) => {
|
||||||
|
if (treffer) return treffer;
|
||||||
|
return fetch(anfrage).then((antwort) => {
|
||||||
|
if (antwort.ok && antwort.type === 'basic') {
|
||||||
|
const kopie = antwort.clone();
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.put(anfrage, kopie))
|
||||||
|
.catch(() => undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return antwort;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -85,7 +85,8 @@
|
|||||||
|
|
||||||
<h2>Marke und Angebot</h2>
|
<h2>Marke und Angebot</h2>
|
||||||
<p><strong>desfoto</strong> ist der Markenauftritt von Denny Schulz Fotografie. Alle über diese
|
<p><strong>desfoto</strong> ist der Markenauftritt von Denny Schulz Fotografie. Alle über diese
|
||||||
Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht.</p>
|
Website erreichbaren Leistungen werden von Denny Schulz Fotografie erbracht. Das kostenlose
|
||||||
|
GTIN-Tool ist ein browserbasiertes Zusatzwerkzeug desselben Anbieters.</p>
|
||||||
|
|
||||||
<h2>Urheberrecht und Bildrechte</h2>
|
<h2>Urheberrecht und Bildrechte</h2>
|
||||||
<p>Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.
|
<p>Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url><loc>https://desfoto.de/</loc><lastmod>2026-09-19</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
<url><loc>https://desfoto.de/</loc><lastmod>2026-09-25</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
||||||
<url><loc>https://desfoto.de/fotografie/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/fotografie/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/businessfotografie/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/businessfotografie/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/portrait-und-model/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/portrait-und-model/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/familie/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/familie/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/minishootings/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/minishootings/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/video/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/video/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/musik-und-buehne/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/musik-und-buehne/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/social-media/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
<url><loc>https://desfoto.de/social-media/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.9</priority></url>
|
||||||
<url><loc>https://desfoto.de/projekte/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
<url><loc>https://desfoto.de/projekte/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||||
<url><loc>https://desfoto.de/ueber/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
<url><loc>https://desfoto.de/ueber/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||||
<url><loc>https://desfoto.de/kontakt/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
<url><loc>https://desfoto.de/kontakt/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||||
<url><loc>https://desfoto.de/impressum/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
|
<url><loc>https://desfoto.de/impressum/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
|
||||||
<url><loc>https://desfoto.de/datenschutz/</loc><lastmod>2026-09-19</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
|
<url><loc>https://desfoto.de/datenschutz/</loc><lastmod>2026-09-25</lastmod><changefreq>monthly</changefreq><priority>0.4</priority></url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
774
src/gtin/css/app.css
Normal file
@@ -0,0 +1,774 @@
|
|||||||
|
/* GTIN Generator – bewusst klein, hell und scanfreundlich. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--schwarz: #111111;
|
||||||
|
--text: #111111;
|
||||||
|
--text-leise: #4b5563;
|
||||||
|
--linie: #d1d5db;
|
||||||
|
--flaeche: #ffffff;
|
||||||
|
--hintergrund: #f4f5f7;
|
||||||
|
--fokus: #1d4ed8;
|
||||||
|
--radius: 12px;
|
||||||
|
--abstand: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--hintergrund);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.scan-offen {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.liste-offen {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--fokus);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) var(--abstand)
|
||||||
|
calc(var(--abstand) + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fuer Hilfstechnik vorhanden, ohne Platz auf dem Bildschirm zu kosten. */
|
||||||
|
.nur-vorlesen {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hinweis-badge {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.karte {
|
||||||
|
background: var(--flaeche);
|
||||||
|
border: 1px solid var(--linie);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--abstand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld-titel {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 2px solid var(--linie);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld:focus {
|
||||||
|
border-color: var(--schwarz);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feld-hinweis {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fehler {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ergebnis {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtin-typ {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gtin {
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(1.9rem, 9vw, 2.75rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 4px;
|
||||||
|
background: var(--flaeche);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen {
|
||||||
|
margin-top: var(--abstand);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen-reihe {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen-reihe .knopf {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf {
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid var(--schwarz);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf-stark {
|
||||||
|
background: var(--schwarz);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf-leise {
|
||||||
|
border-color: transparent;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-leise);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knopf:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-titel {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-liste {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-liste li + li {
|
||||||
|
border-top: 1px solid var(--linie);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-eintrag {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-eintrag:hover,
|
||||||
|
.verlauf-eintrag:focus-visible {
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-artikel {
|
||||||
|
min-width: 5.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-pfeil {
|
||||||
|
color: var(--text-leise);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verlauf-gtin {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-hinweis {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(20px + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--schwarz);
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kopier-hilfe {
|
||||||
|
position: fixed;
|
||||||
|
top: -1000px;
|
||||||
|
left: -1000px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Scanmodus -- */
|
||||||
|
|
||||||
|
.scan {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) 12px
|
||||||
|
calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-innen {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-seite {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-barcode {
|
||||||
|
width: 100%;
|
||||||
|
/* Nimmt im Hochformat die verbleibende Hoehe ein, damit der Barcode so
|
||||||
|
gross wie moeglich wird. Die Groesse wird in app.js gemessen. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-barcode svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-gtin {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(1.5rem, 7vw, 2.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-hinweis {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-zurueck {
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Querformat: Der Barcode bekommt die volle Breite des Bildschirms, GTIN und
|
||||||
|
* Zurueck-Schaltflaeche stehen daneben. Dadurch wird der grosse Code deutlich
|
||||||
|
* groesser als im Hochformat, wo die Beschriftung darunter liegt.
|
||||||
|
*/
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.scan-innen {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-barcode {
|
||||||
|
width: auto;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-seite {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 34%;
|
||||||
|
align-self: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-gtin {
|
||||||
|
font-size: clamp(1rem, 4.4vh, 1.9rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-hinweis {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-zurueck {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- Listenmodus -- */
|
||||||
|
|
||||||
|
/* Der Einstieg ist bewusst ein kleiner Link unter dem Werkzeug: Die
|
||||||
|
Einzelansicht bleibt die Hauptansicht. */
|
||||||
|
.fuss-liste {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-datenschutz {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-datenschutz a,
|
||||||
|
.fuss-rechtliches a {
|
||||||
|
color: inherit;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuss-rechtliches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-klein {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-klein:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--flaeche);
|
||||||
|
padding: calc(12px + env(safe-area-inset-top)) var(--abstand)
|
||||||
|
calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-innen {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 44rem;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-erfassen,
|
||||||
|
.liste-anzeige {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schritt 1: Nummern erfassen. */
|
||||||
|
|
||||||
|
.liste-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Abstaende liegen am Formular, nicht an den einzelnen Elementen. */
|
||||||
|
.liste-form .feld-titel,
|
||||||
|
.liste-form .feld-hinweis,
|
||||||
|
.liste-form .fehler {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Feld und Bestaetigung bilden eine Zeile: Der Knopf sitzt unmittelbar neben
|
||||||
|
* der Eingabe, damit das Bestaetigen auf dem Telefon nicht von der
|
||||||
|
* Bildschirmtastatur abhaengt. Auf sehr schmalen Anzeigen bricht er unter das
|
||||||
|
* Feld und bleibt dort ein volles Touchziel.
|
||||||
|
*/
|
||||||
|
.liste-eingabezeile {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eingabezeile .feld {
|
||||||
|
flex: 1 1 9rem;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-hinzufuegen {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-titel {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-anzahl {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.6rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--schwarz);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintraege {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintrag {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-top: 1px solid var(--linie);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintrag:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-nr {
|
||||||
|
min-width: 5.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-eintrag .liste-gtin {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-weg {
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-weg:hover,
|
||||||
|
.liste-weg:focus-visible {
|
||||||
|
background: var(--hintergrund);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-leer {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-aktionen,
|
||||||
|
.liste-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schritt 2: GTIN und Barcode nacheinander anzeigen. */
|
||||||
|
|
||||||
|
.liste-zaehler {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-buehne {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"mitte mitte"
|
||||||
|
"zurueck vor";
|
||||||
|
gap: 10px;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-mitte {
|
||||||
|
grid-area: mitte;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-barcode {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-barcode svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-gtin {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: clamp(1.4rem, 6.5vw, 2.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-pfeil {
|
||||||
|
min-height: 3rem;
|
||||||
|
border: 2px solid var(--schwarz);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--flaeche);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#liste-zurueck {
|
||||||
|
grid-area: zurueck;
|
||||||
|
}
|
||||||
|
|
||||||
|
#liste-vor {
|
||||||
|
grid-area: vor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-artikel {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-leise);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Schritt zur naechsten Nummer zeigt die Richtung: Der neue Barcode kommt
|
||||||
|
* von der Seite, in die der Nutzer geblättert hat. 160 ms, einmalig, und unter
|
||||||
|
* "prefers-reduced-motion: reduce" abgeschaltet.
|
||||||
|
*/
|
||||||
|
@keyframes liste-schritt-vor {
|
||||||
|
from {
|
||||||
|
transform: translateX(14px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes liste-schritt-zurueck {
|
||||||
|
from {
|
||||||
|
transform: translateX(-14px);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-schritt-vor {
|
||||||
|
animation: liste-schritt-vor 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-schritt-zurueck {
|
||||||
|
animation: liste-schritt-zurueck 160ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Querformat: Die Pfeile stehen als Leisten neben dem Barcode, der dadurch die
|
||||||
|
* volle Hoehe bekommt.
|
||||||
|
*/
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.liste-buehne {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
grid-template-areas: "zurueck mitte vor";
|
||||||
|
}
|
||||||
|
|
||||||
|
.liste-pfeil {
|
||||||
|
width: 4rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.aktionen {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen .knopf-stark {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktionen-reihe {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/gtin/icons/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 32" role="img" aria-label="GTIN Generator"><rect width="35" height="32" fill="#ffffff"/><g fill="#111111"><rect x="0" y="0" width="1" height="32"/><rect x="2" y="0" width="1" height="32"/><rect x="5" y="0" width="1" height="32"/><rect x="7" y="0" width="2" height="32"/><rect x="11" y="0" width="1" height="32"/><rect x="14" y="0" width="1" height="32"/><rect x="16" y="0" width="1" height="32"/><rect x="19" y="0" width="2" height="32"/><rect x="22" y="0" width="1" height="32"/><rect x="25" y="0" width="1" height="32"/><rect x="27" y="0" width="2" height="32"/><rect x="30" y="0" width="2" height="32"/><rect x="33" y="0" width="1" height="32"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 715 B |
BIN
src/gtin/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 251 B |
BIN
src/gtin/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/gtin/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
190
src/gtin/index.html
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>GTIN Generator · desfoto</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen. Kostenlos, lokal im Browser und ohne Anmeldung."
|
||||||
|
/>
|
||||||
|
<link rel="canonical" href="https://desfoto.de/gtin/" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GTIN" />
|
||||||
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
|
<link rel="icon" href="./icons/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
|
<link rel="stylesheet" href="./css/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app">
|
||||||
|
<!-- Kein sichtbarer Titel und keine Beschreibung: Auf dem Bildschirm
|
||||||
|
zaehlt nur die Eingabe mit GTIN und Barcode. Die Ueberschrift bleibt
|
||||||
|
fuer Hilfstechnik vorhanden. -->
|
||||||
|
<h1 class="nur-vorlesen">GTIN Generator</h1>
|
||||||
|
<p class="hinweis-badge" id="offline-hinweis" hidden>Offline – die Berechnung läuft weiter.</p>
|
||||||
|
|
||||||
|
<section class="karte eingabe" aria-labelledby="eingabe-titel">
|
||||||
|
<h2 class="feld-titel" id="eingabe-titel">Artikelnummer</h2>
|
||||||
|
<input
|
||||||
|
class="feld"
|
||||||
|
id="artikelnummer"
|
||||||
|
name="artikelnummer"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
enterkeyhint="go"
|
||||||
|
placeholder="z. B. 5122"
|
||||||
|
aria-labelledby="eingabe-titel"
|
||||||
|
aria-describedby="format-hinweis"
|
||||||
|
/>
|
||||||
|
<p class="feld-hinweis" id="format-hinweis">Erlaubt sind 3, 4 oder 7 Ziffern.</p>
|
||||||
|
<p class="fehler" id="fehler" role="alert" hidden></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="karte ergebnis" id="ergebnis" aria-live="polite" hidden>
|
||||||
|
<p class="gtin-typ" id="gtin-typ"></p>
|
||||||
|
<button class="gtin" id="gtin" type="button" title="GTIN kopieren"></button>
|
||||||
|
<div class="barcode" id="barcode"></div>
|
||||||
|
<div class="aktionen">
|
||||||
|
<button class="knopf knopf-stark" id="gross" type="button">Barcode groß anzeigen</button>
|
||||||
|
<div class="aktionen-reihe">
|
||||||
|
<button class="knopf" id="kopieren" type="button">GTIN kopieren</button>
|
||||||
|
<button class="knopf" id="neu" type="button">Neue Artikelnummer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="karte verlauf" id="verlauf" hidden aria-labelledby="verlauf-titel">
|
||||||
|
<h2 class="verlauf-titel" id="verlauf-titel">Zuletzt verwendet</h2>
|
||||||
|
<ul class="verlauf-liste" id="verlauf-liste"></ul>
|
||||||
|
<button class="knopf knopf-leise" id="verlauf-loeschen" type="button">Verlauf löschen</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="fuss">
|
||||||
|
<button class="knopf knopf-stark" id="installieren" type="button" hidden>
|
||||||
|
App installieren
|
||||||
|
</button>
|
||||||
|
<p class="fuss-hinweis" id="ios-hinweis" hidden>
|
||||||
|
Installieren: Teilen → „Zum Home-Bildschirm“.
|
||||||
|
</p>
|
||||||
|
<!-- Kleiner Einstieg in den optionalen Listenmodus: Die Einzelansicht
|
||||||
|
bleibt unveraendert, die Liste liegt darunter als Link. -->
|
||||||
|
<p class="fuss-liste">
|
||||||
|
<button class="link-klein" id="liste-oeffnen" type="button" aria-haspopup="dialog">
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
<p class="fuss-datenschutz">
|
||||||
|
Eingaben und Verlauf bleiben in deinem Browser.
|
||||||
|
<a href="/datenschutz/">Datenschutz</a>
|
||||||
|
</p>
|
||||||
|
<nav class="fuss-rechtliches" aria-label="Rechtliche Informationen">
|
||||||
|
<a href="/impressum/">Impressum</a>
|
||||||
|
<a href="/datenschutz/">Datenschutz</a>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="scan" id="scan" role="dialog" aria-modal="true" aria-label="Barcode im Scanmodus" hidden>
|
||||||
|
<div class="scan-innen" id="scan-innen" tabindex="-1">
|
||||||
|
<div class="scan-barcode" id="scan-barcode"></div>
|
||||||
|
<div class="scan-seite">
|
||||||
|
<p class="scan-gtin" id="scan-gtin"></p>
|
||||||
|
<p class="scan-hinweis" id="scan-hinweis" hidden>
|
||||||
|
Barcode wird nicht erkannt? Displayhelligkeit erhöhen.
|
||||||
|
</p>
|
||||||
|
<button class="knopf knopf-stark scan-zurueck" id="scan-zurueck" type="button">Zurück</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="toast" id="toast" role="status" aria-live="polite" hidden></p>
|
||||||
|
|
||||||
|
<div class="liste" id="liste" role="dialog" aria-modal="true" aria-label="Liste" hidden>
|
||||||
|
<div class="liste-innen" id="liste-innen">
|
||||||
|
<!-- Schritt 1: Nummern eingeben, jede ueber die Schaltflaeche oder die
|
||||||
|
Bestaetigungstaste der Bildschirmtastatur in die Liste uebernehmen. -->
|
||||||
|
<section class="liste-erfassen" id="liste-erfassen" aria-labelledby="liste-titel">
|
||||||
|
<h2 class="liste-titel" id="liste-titel">
|
||||||
|
Liste <span class="liste-anzahl" id="liste-anzahl">0</span>
|
||||||
|
</h2>
|
||||||
|
<!-- Ein echtes Formular: Ein Numpad auf dem Telefon hat keine
|
||||||
|
Eingabetaste, seine Bestaetigungstaste (Haekchen) loest aber das
|
||||||
|
submit-Ereignis aus. Der sichtbare Knopf deckt jedes Geraet ab. -->
|
||||||
|
<form class="liste-form" id="liste-form" novalidate>
|
||||||
|
<label class="feld-titel" for="liste-eingabe">Artikelnummer</label>
|
||||||
|
<div class="liste-eingabezeile">
|
||||||
|
<input
|
||||||
|
class="feld"
|
||||||
|
id="liste-eingabe"
|
||||||
|
name="liste-eingabe"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
enterkeyhint="done"
|
||||||
|
placeholder="z. B. 5122"
|
||||||
|
aria-describedby="liste-format"
|
||||||
|
/>
|
||||||
|
<button class="knopf knopf-stark liste-hinzufuegen" id="liste-hinzufuegen" type="submit">
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="feld-hinweis" id="liste-format">
|
||||||
|
Hinzufügen übernimmt die Nummer; mehrere Nummern dürfen durch Leerzeichen getrennt sein.
|
||||||
|
</p>
|
||||||
|
<p class="fehler" id="liste-fehler" role="alert" hidden></p>
|
||||||
|
</form>
|
||||||
|
<ul class="liste-eintraege" id="liste-eintraege"></ul>
|
||||||
|
<p class="liste-leer" id="liste-leer">Noch keine Nummer in der Liste.</p>
|
||||||
|
<div class="liste-aktionen">
|
||||||
|
<button class="knopf" id="liste-weiter" type="button" disabled>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
<button class="link-klein" id="liste-schliessen" type="button">
|
||||||
|
Zurück zur Einzelansicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Schritt 2: GTIN und Barcode nacheinander anzeigen. -->
|
||||||
|
<section class="liste-anzeige" id="liste-anzeige" aria-label="Barcodes der Liste" hidden>
|
||||||
|
<p class="liste-zaehler" id="liste-zaehler"></p>
|
||||||
|
<div class="liste-buehne" id="liste-buehne">
|
||||||
|
<button class="liste-pfeil" id="liste-zurueck" type="button" aria-label="Vorheriger Eintrag">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div class="liste-mitte" id="liste-mitte" tabindex="-1">
|
||||||
|
<div class="liste-barcode" id="liste-barcode"></div>
|
||||||
|
<p class="liste-gtin" id="liste-gtin" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
<button class="liste-pfeil" id="liste-vor" type="button" aria-label="Nächster Eintrag">
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="liste-artikel" id="liste-artikel"></p>
|
||||||
|
<div class="liste-links">
|
||||||
|
<button class="link-klein" id="liste-aendern" type="button">Liste ändern</button>
|
||||||
|
<button class="link-klein" id="liste-beenden" type="button">
|
||||||
|
Zurück zur Einzelansicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
998
src/gtin/js/app.js
Normal file
@@ -0,0 +1,998 @@
|
|||||||
|
/**
|
||||||
|
* Oberflaeche, Scanmodus, Zwischenablage, Verlauf, Wake Lock und PWA.
|
||||||
|
*
|
||||||
|
* Die fachliche Logik liegt in gtin.js, die Codierung in barcode.js. Diese
|
||||||
|
* Datei verdrahtet beides mit dem DOM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { eingabeStatus, erzeugeGtin, FEHLERTEXT, nurZiffern } from './gtin.js';
|
||||||
|
import { barcodeGroesse, erzeugeSvgMarkup, kodiereEan } from './barcode.js';
|
||||||
|
import { schritt, istBestaetigungstaste, zaehlerText, zerlegeEingabe } from './liste.js';
|
||||||
|
|
||||||
|
/** @typedef {import('./gtin.js').GtinErgebnis} GtinErgebnis */
|
||||||
|
|
||||||
|
/** @typedef {{ artikel: string, gtin: string, typ: string }} VerlaufEintrag */
|
||||||
|
/** @typedef {{ released: boolean, release: () => Promise<void>, addEventListener: (typ: 'release', hoerer: () => void) => void }} WakeLockLike */
|
||||||
|
|
||||||
|
const VERLAUF_SCHLUESSEL = 'desfoto.gtin.verlauf.v1';
|
||||||
|
const VERLAUF_MAX = 20;
|
||||||
|
const EINGABE_VERZOEGERUNG_MS = 450;
|
||||||
|
const VERLAUF_VERZOEGERUNG_MS = 2500;
|
||||||
|
const SCAN_HINWEIS_MS = 7000;
|
||||||
|
const TOAST_MS = 1800;
|
||||||
|
/** Anteil der Bildschirmhoehe, den der Barcode in der Normalansicht nutzen darf. */
|
||||||
|
const BALKENHOEHE_ANTEIL = 0.45;
|
||||||
|
/** Untergrenze, damit der Barcode auch auf kurzen Anzeigen scanbar bleibt. */
|
||||||
|
const BALKENHOEHE_MIN_PX = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} auswahl
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function element(auswahl) {
|
||||||
|
const gefunden = document.querySelector(auswahl);
|
||||||
|
if (!gefunden) throw new Error(`Pflichtelement fehlt: ${auswahl}`);
|
||||||
|
return /** @type {HTMLElement} */ (gefunden);
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = /** @type {HTMLInputElement} */ (element('#artikelnummer'));
|
||||||
|
const fehlerText = element('#fehler');
|
||||||
|
const ergebnisBereich = element('#ergebnis');
|
||||||
|
const gtinTyp = element('#gtin-typ');
|
||||||
|
const gtinKnopf = element('#gtin');
|
||||||
|
const barcodeBehaelter = element('#barcode');
|
||||||
|
const grossKnopf = element('#gross');
|
||||||
|
const kopierenKnopf = element('#kopieren');
|
||||||
|
const neuKnopf = element('#neu');
|
||||||
|
const verlaufBereich = element('#verlauf');
|
||||||
|
const verlaufListe = element('#verlauf-liste');
|
||||||
|
const verlaufLoeschenKnopf = element('#verlauf-loeschen');
|
||||||
|
const installierenKnopf = element('#installieren');
|
||||||
|
const iosHinweis = element('#ios-hinweis');
|
||||||
|
const offlineHinweis = element('#offline-hinweis');
|
||||||
|
const scanBereich = element('#scan');
|
||||||
|
const scanInnen = element('#scan-innen');
|
||||||
|
const scanBarcodeBehaelter = element('#scan-barcode');
|
||||||
|
const scanGtin = element('#scan-gtin');
|
||||||
|
const scanHinweis = element('#scan-hinweis');
|
||||||
|
const scanZurueckKnopf = element('#scan-zurueck');
|
||||||
|
const toast = element('#toast');
|
||||||
|
const appBereich = element('.app');
|
||||||
|
const listeOeffnenKnopf = element('#liste-oeffnen');
|
||||||
|
const listeBereich = element('#liste');
|
||||||
|
const listeForm = /** @type {HTMLFormElement} */ (element('#liste-form'));
|
||||||
|
const listeErfassen = element('#liste-erfassen');
|
||||||
|
const listeAnzeige = element('#liste-anzeige');
|
||||||
|
const listeEingabe = /** @type {HTMLInputElement} */ (element('#liste-eingabe'));
|
||||||
|
const listeHinzufuegenKnopf = /** @type {HTMLButtonElement} */ (element('#liste-hinzufuegen'));
|
||||||
|
const listeFehler = element('#liste-fehler');
|
||||||
|
const listeEintraege = element('#liste-eintraege');
|
||||||
|
const listeLeer = element('#liste-leer');
|
||||||
|
const listeAnzahl = element('#liste-anzahl');
|
||||||
|
const listeWeiterKnopf = /** @type {HTMLButtonElement} */ (element('#liste-weiter'));
|
||||||
|
const listeSchliessenKnopf = element('#liste-schliessen');
|
||||||
|
const listeZaehler = element('#liste-zaehler');
|
||||||
|
const listeBuehne = element('#liste-buehne');
|
||||||
|
const listeMitte = element('#liste-mitte');
|
||||||
|
const listeBarcodeBehaelter = element('#liste-barcode');
|
||||||
|
const listeGtin = element('#liste-gtin');
|
||||||
|
const listeArtikel = element('#liste-artikel');
|
||||||
|
const listeZurueckKnopf = element('#liste-zurueck');
|
||||||
|
const listeVorKnopf = element('#liste-vor');
|
||||||
|
const listeAendernKnopf = element('#liste-aendern');
|
||||||
|
const listeBeendenKnopf = element('#liste-beenden');
|
||||||
|
|
||||||
|
/** @type {GtinErgebnis | null} */
|
||||||
|
let aktuell = null;
|
||||||
|
/** @type {GtinErgebnis[]} */
|
||||||
|
const liste = [];
|
||||||
|
let listeAktiv = false;
|
||||||
|
let listeAnzeigeAktiv = false;
|
||||||
|
let listeIndex = 0;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let eingabeTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let verlaufTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let toastTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let scanHinweisTimer;
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let groessenTimer;
|
||||||
|
/** @type {WakeLockLike | null} */
|
||||||
|
let wakeLock = null;
|
||||||
|
let scanAktiv = false;
|
||||||
|
/** @type {any} */
|
||||||
|
let installationsAnfrage = null;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Verlauf -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} eintrag
|
||||||
|
* @returns {eintrag is VerlaufEintrag}
|
||||||
|
*/
|
||||||
|
function istVerlaufEintrag(eintrag) {
|
||||||
|
if (typeof eintrag !== 'object' || eintrag === null) return false;
|
||||||
|
const kandidat = /** @type {Record<string, unknown>} */ (eintrag);
|
||||||
|
return (
|
||||||
|
typeof kandidat.artikel === 'string' &&
|
||||||
|
typeof kandidat.gtin === 'string' &&
|
||||||
|
typeof kandidat.typ === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {VerlaufEintrag[]} */
|
||||||
|
function ladeVerlauf() {
|
||||||
|
try {
|
||||||
|
const roh = window.localStorage.getItem(VERLAUF_SCHLUESSEL);
|
||||||
|
if (!roh) return [];
|
||||||
|
const daten = /** @type {unknown} */ (JSON.parse(roh));
|
||||||
|
if (!Array.isArray(daten)) return [];
|
||||||
|
return daten.filter(istVerlaufEintrag).slice(0, VERLAUF_MAX);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {VerlaufEintrag[]} eintraege */
|
||||||
|
function speichereVerlaufListe(eintraege) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(VERLAUF_SCHLUESSEL, JSON.stringify(eintraege));
|
||||||
|
} catch {
|
||||||
|
/* Speicher gesperrt oder voll: der Verlauf ist optional. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {GtinErgebnis} ergebnis */
|
||||||
|
function merkeImVerlauf(ergebnis) {
|
||||||
|
const eintrag = { artikel: ergebnis.eingabe, gtin: ergebnis.gtin, typ: ergebnis.typ };
|
||||||
|
const bisher = ladeVerlauf().filter((vorhanden) => vorhanden.gtin !== eintrag.gtin);
|
||||||
|
speichereVerlaufListe([eintrag, ...bisher].slice(0, VERLAUF_MAX));
|
||||||
|
rendereVerlauf();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendereVerlauf() {
|
||||||
|
const eintraege = ladeVerlauf();
|
||||||
|
verlaufListe.replaceChildren();
|
||||||
|
for (const eintrag of eintraege) {
|
||||||
|
const punkt = document.createElement('li');
|
||||||
|
const knopf = document.createElement('button');
|
||||||
|
knopf.type = 'button';
|
||||||
|
knopf.className = 'verlauf-eintrag';
|
||||||
|
knopf.dataset.artikel = eintrag.artikel;
|
||||||
|
knopf.dataset.gtin = eintrag.gtin;
|
||||||
|
|
||||||
|
const artikel = document.createElement('span');
|
||||||
|
artikel.className = 'verlauf-artikel';
|
||||||
|
artikel.textContent = eintrag.artikel;
|
||||||
|
|
||||||
|
const pfeil = document.createElement('span');
|
||||||
|
pfeil.className = 'verlauf-pfeil';
|
||||||
|
pfeil.setAttribute('aria-hidden', 'true');
|
||||||
|
pfeil.textContent = '→';
|
||||||
|
|
||||||
|
const nummer = document.createElement('span');
|
||||||
|
nummer.className = 'verlauf-gtin';
|
||||||
|
nummer.textContent = eintrag.gtin;
|
||||||
|
|
||||||
|
knopf.append(artikel, pfeil, nummer);
|
||||||
|
knopf.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`Artikelnummer ${eintrag.artikel}, GTIN ${eintrag.gtin} erneut anzeigen`,
|
||||||
|
);
|
||||||
|
punkt.append(knopf);
|
||||||
|
verlaufListe.append(punkt);
|
||||||
|
}
|
||||||
|
verlaufBereich.hidden = eintraege.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leereVerlauf() {
|
||||||
|
speichereVerlaufListe([]);
|
||||||
|
rendereVerlauf();
|
||||||
|
zeigeToast('Verlauf gelöscht');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Anzeige -- */
|
||||||
|
|
||||||
|
function versteckeFehler() {
|
||||||
|
fehlerText.hidden = true;
|
||||||
|
fehlerText.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeFehler() {
|
||||||
|
fehlerText.textContent = FEHLERTEXT;
|
||||||
|
fehlerText.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versteckeErgebnis() {
|
||||||
|
aktuell = null;
|
||||||
|
ergebnisBereich.hidden = true;
|
||||||
|
barcodeBehaelter.replaceChildren();
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verfuegbare Hoehe fuer den Barcode in der Normalansicht. */
|
||||||
|
function normalHoehe() {
|
||||||
|
return Math.max(
|
||||||
|
BALKENHOEHE_MIN_PX,
|
||||||
|
Math.round(window.innerHeight * BALKENHOEHE_ANTEIL),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} behaelter
|
||||||
|
* @param {GtinErgebnis} ergebnis
|
||||||
|
* @param {'normal' | 'scan'} modus
|
||||||
|
*/
|
||||||
|
function rendereBarcode(behaelter, ergebnis, modus) {
|
||||||
|
const symbol = kodiereEan(ergebnis.gtin);
|
||||||
|
const gesamtModule =
|
||||||
|
symbol.quietzone.links + symbol.module + symbol.quietzone.rechts;
|
||||||
|
const gemessen = behaelter.getBoundingClientRect().width || behaelter.clientWidth;
|
||||||
|
const verfuegbareBreite = Math.max(1, Math.round(gemessen || 320));
|
||||||
|
// Im Scanmodus bestimmt die gemessene Flaeche die Groesse (Querformat: volle
|
||||||
|
// Breite und Hoehe). In der Normalansicht deckelt ein Anteil der
|
||||||
|
// Bildschirmhoehe den Barcode, damit die wichtigsten Bedienelemente ohne
|
||||||
|
// Scrollen sichtbar bleiben.
|
||||||
|
const verfuegbareHoehe =
|
||||||
|
modus === 'scan'
|
||||||
|
? Math.max(1, Math.round(behaelter.clientHeight || normalHoehe()))
|
||||||
|
: normalHoehe();
|
||||||
|
const { modulbreite: modulbreitePx, balkenhoeheModule } = barcodeGroesse(
|
||||||
|
verfuegbareBreite,
|
||||||
|
verfuegbareHoehe,
|
||||||
|
gesamtModule,
|
||||||
|
);
|
||||||
|
const svg = erzeugeSvgMarkup(ergebnis.gtin, {
|
||||||
|
modulbreite: modulbreitePx,
|
||||||
|
balkenhoeheModule,
|
||||||
|
});
|
||||||
|
behaelter.innerHTML = svg.markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoppeVerlaufTimer() {
|
||||||
|
if (verlaufTimer !== undefined) {
|
||||||
|
window.clearTimeout(verlaufTimer);
|
||||||
|
verlaufTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uebernimmt das aktuelle Ergebnis in den Verlauf, sobald es bestaetigt wirkt. */
|
||||||
|
function merkeAktuellesNachKurzerPause() {
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
verlaufTimer = window.setTimeout(() => {
|
||||||
|
verlaufTimer = undefined;
|
||||||
|
if (aktuell) merkeImVerlauf(aktuell);
|
||||||
|
}, VERLAUF_VERZOEGERUNG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {GtinErgebnis} ergebnis */
|
||||||
|
function zeigeErgebnis(ergebnis) {
|
||||||
|
aktuell = ergebnis;
|
||||||
|
versteckeFehler();
|
||||||
|
gtinTyp.textContent = ergebnis.typ === 'EAN-8' ? 'GTIN-8' : 'GTIN-13';
|
||||||
|
gtinKnopf.textContent = ergebnis.gtin;
|
||||||
|
ergebnisBereich.hidden = false;
|
||||||
|
rendereBarcode(barcodeBehaelter, ergebnis, 'normal');
|
||||||
|
merkeAktuellesNachKurzerPause();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruefeUndZeige() {
|
||||||
|
const status = eingabeStatus(input.value);
|
||||||
|
if (status === 'leer') {
|
||||||
|
versteckeFehler();
|
||||||
|
versteckeErgebnis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auch eine zu kurze Nummer ist am Ende ungueltig. Der Fehler erscheint erst
|
||||||
|
// nach der Entprellung, waehrend des Tippens also nicht.
|
||||||
|
if (status === 'unvollstaendig' || status === 'ungueltig') {
|
||||||
|
versteckeErgebnis();
|
||||||
|
zeigeFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ergebnis = erzeugeGtin(input.value);
|
||||||
|
if (!ergebnis) {
|
||||||
|
versteckeErgebnis();
|
||||||
|
zeigeFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zeigeErgebnis(ergebnis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Eingabe -- */
|
||||||
|
|
||||||
|
function stoppeEingabeTimer() {
|
||||||
|
if (eingabeTimer !== undefined) {
|
||||||
|
window.clearTimeout(eingabeTimer);
|
||||||
|
eingabeTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beiEingabe() {
|
||||||
|
const sauber = nurZiffern(input.value);
|
||||||
|
if (sauber !== input.value) {
|
||||||
|
input.value = sauber;
|
||||||
|
input.setSelectionRange(sauber.length, sauber.length);
|
||||||
|
}
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
if (sauber.length === 0) {
|
||||||
|
versteckeFehler();
|
||||||
|
versteckeErgebnis();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sauber.length === 7) {
|
||||||
|
pruefeUndZeige();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eingabeTimer = window.setTimeout(() => {
|
||||||
|
eingabeTimer = undefined;
|
||||||
|
pruefeUndZeige();
|
||||||
|
}, EINGABE_VERZOEGERUNG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebernimmt eine Artikelnummer in das Eingabefeld.
|
||||||
|
*
|
||||||
|
* @param {string} artikel
|
||||||
|
* @param {{ fokussieren?: boolean }} [optionen] `fokussieren: false` laesst den
|
||||||
|
* Fokus, wo er ist: Ein Tipp auf den Verlauf soll den Barcode zeigen und auf
|
||||||
|
* Mobilgeraeten nicht die Tastatur ueber das Ergebnis legen.
|
||||||
|
*/
|
||||||
|
function uebernimmArtikel(artikel, optionen = {}) {
|
||||||
|
input.value = artikel;
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
pruefeUndZeige();
|
||||||
|
if (optionen.fokussieren ?? true) input.focus();
|
||||||
|
// Der Verlauf steht unter dem Ergebnis: Nach dem Uebernehmen dorthin
|
||||||
|
// scrollen, damit der erzeugte Barcode sichtbar ist.
|
||||||
|
ergebnisBereich.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- Feld -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert die komplette Artikelnummer.
|
||||||
|
*
|
||||||
|
* Ein Tipp auf das Feld waehlt damit die alte Nummer aus: Die naechste Ziffer
|
||||||
|
* ueberschreibt sie direkt, ohne dass vorher geloescht werden muss.
|
||||||
|
*/
|
||||||
|
function markiereEingabe() {
|
||||||
|
if (input.value === '') {
|
||||||
|
versteckeFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.setSelectionRange(0, input.value.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** true, wenn das Feld beim letzten Zeigerdruck bereits den Fokus hatte. */
|
||||||
|
let feldWarFokussiert = false;
|
||||||
|
|
||||||
|
function neueNummer() {
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
input.value = '';
|
||||||
|
versteckeFehler();
|
||||||
|
versteckeErgebnis();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Scanmodus -- */
|
||||||
|
|
||||||
|
/** @returns {{ request: (typ: 'screen') => Promise<WakeLockLike> } | null} */
|
||||||
|
function wakeLockApi() {
|
||||||
|
const api = /** @type {any} */ (navigator).wakeLock;
|
||||||
|
return api && typeof api.request === 'function' ? api : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function starteWakeLock() {
|
||||||
|
const api = wakeLockApi();
|
||||||
|
if (!api || wakeLock) return;
|
||||||
|
api
|
||||||
|
.request('screen')
|
||||||
|
.then((sentinel) => {
|
||||||
|
wakeLock = sentinel;
|
||||||
|
sentinel.addEventListener('release', () => {
|
||||||
|
if (wakeLock === sentinel) wakeLock = null;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
wakeLock = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoppeWakeLock() {
|
||||||
|
const sentinel = wakeLock;
|
||||||
|
wakeLock = null;
|
||||||
|
if (sentinel) sentinel.release().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oeffneScan() {
|
||||||
|
if (!aktuell || scanAktiv) return;
|
||||||
|
scanAktiv = true;
|
||||||
|
scanGtin.textContent = aktuell.gtin;
|
||||||
|
scanBereich.hidden = false;
|
||||||
|
document.body.classList.add('scan-offen');
|
||||||
|
// Der Rest der Oberflaeche wird unerreichbar: Tastatur und Hilfstechnik
|
||||||
|
// bleiben im Scanmodus, weil nur noch der Dialog bedienbar ist.
|
||||||
|
appBereich.toggleAttribute('inert', true);
|
||||||
|
rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
|
||||||
|
scanInnen.focus();
|
||||||
|
starteWakeLock();
|
||||||
|
scanHinweisTimer = window.setTimeout(() => {
|
||||||
|
scanHinweisTimer = undefined;
|
||||||
|
if (scanAktiv) scanHinweis.hidden = false;
|
||||||
|
}, SCAN_HINWEIS_MS);
|
||||||
|
merkeImVerlauf(aktuell);
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseScan() {
|
||||||
|
if (!scanAktiv) return;
|
||||||
|
scanAktiv = false;
|
||||||
|
scanBereich.hidden = true;
|
||||||
|
scanHinweis.hidden = true;
|
||||||
|
appBereich.toggleAttribute('inert', false);
|
||||||
|
document.body.classList.remove('scan-offen');
|
||||||
|
if (scanHinweisTimer !== undefined) {
|
||||||
|
window.clearTimeout(scanHinweisTimer);
|
||||||
|
scanHinweisTimer = undefined;
|
||||||
|
}
|
||||||
|
stoppeWakeLock();
|
||||||
|
grossKnopf.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Listenmodus -- */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Optionaler Modus: Die Liste liegt als kleiner Link unter dem Werkzeug. Sie
|
||||||
|
* wird in zwei Schritten bedient - Nummern bestaetigen, dann mit Pfeilen,
|
||||||
|
* Pfeiltasten oder Wischen GTIN und Barcode nacheinander anzeigen. Beide
|
||||||
|
* Schritte enden ueber die kleinen Links zurueck in die Einzelansicht.
|
||||||
|
*
|
||||||
|
* Bestaetigt wird ueber das Formular: Auf einem Telefon hat das Numpad keine
|
||||||
|
* Eingabetaste, seine Bestaetigungstaste loest aber das submit-Ereignis aus.
|
||||||
|
* Zusaetzlich liegt unmittelbar neben dem Feld eine sichtbare Schaltflaeche,
|
||||||
|
* damit die Bestaetigung auf keinem Geraet von der Tastatur abhaengt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function versteckeListenFehler() {
|
||||||
|
listeFehler.hidden = true;
|
||||||
|
listeFehler.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeListenFehler() {
|
||||||
|
listeFehler.textContent = FEHLERTEXT;
|
||||||
|
listeFehler.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendereListe() {
|
||||||
|
listeEintraege.replaceChildren();
|
||||||
|
liste.forEach((eintrag, index) => {
|
||||||
|
const punkt = document.createElement('li');
|
||||||
|
punkt.className = 'liste-eintrag';
|
||||||
|
|
||||||
|
const nummer = document.createElement('span');
|
||||||
|
nummer.className = 'liste-nr';
|
||||||
|
nummer.textContent = eintrag.eingabe;
|
||||||
|
|
||||||
|
const gtin = document.createElement('span');
|
||||||
|
gtin.className = 'liste-gtin';
|
||||||
|
gtin.textContent = eintrag.gtin;
|
||||||
|
|
||||||
|
const weg = document.createElement('button');
|
||||||
|
weg.type = 'button';
|
||||||
|
weg.className = 'liste-weg';
|
||||||
|
weg.dataset.index = String(index);
|
||||||
|
weg.setAttribute('aria-label', `Artikelnummer ${eintrag.eingabe} aus der Liste entfernen`);
|
||||||
|
weg.textContent = '×';
|
||||||
|
|
||||||
|
punkt.append(nummer, gtin, weg);
|
||||||
|
listeEintraege.append(punkt);
|
||||||
|
});
|
||||||
|
|
||||||
|
listeAnzahl.textContent = String(liste.length);
|
||||||
|
listeLeer.hidden = liste.length > 0;
|
||||||
|
listeWeiterKnopf.disabled = liste.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uebernimmt die Eingabe in die Liste.
|
||||||
|
*
|
||||||
|
* Alles, was keine Ziffer ist, trennt Nummern: So laesst sich eine ganze Spalte
|
||||||
|
* in einem Zug einfuegen. Die Bestaetigung im wirklich leeren Feld startet
|
||||||
|
* direkt die Anzeige, damit die Erfassung ohne Klick auf "Weiter" endet. Steht
|
||||||
|
* dagegen Text ohne eine einzige Ziffer im Feld, ist das ein Fehler und kein
|
||||||
|
* Start.
|
||||||
|
*/
|
||||||
|
function uebernimmListeneingabe() {
|
||||||
|
const roh = listeEingabe.value;
|
||||||
|
const gruppen = zerlegeEingabe(roh);
|
||||||
|
if (gruppen.length === 0) {
|
||||||
|
if (roh.trim() !== '') {
|
||||||
|
zeigeListenFehler();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
versteckeListenFehler();
|
||||||
|
if (liste.length > 0) starteListenAnzeige(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let uebernommen = 0;
|
||||||
|
let verworfen = 0;
|
||||||
|
for (const gruppe of gruppen) {
|
||||||
|
const ergebnis = erzeugeGtin(gruppe);
|
||||||
|
if (ergebnis) {
|
||||||
|
liste.push(ergebnis);
|
||||||
|
uebernommen += 1;
|
||||||
|
} else {
|
||||||
|
verworfen += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uebernommen > 0) {
|
||||||
|
listeEingabe.value = '';
|
||||||
|
rendereListe();
|
||||||
|
listeEintraege.scrollTop = listeEintraege.scrollHeight;
|
||||||
|
// Der Fokus bleibt im Feld: Die Bildschirmtastatur bleibt offen, und die
|
||||||
|
// naechste Nummer laesst sich ohne einen weiteren Tipp eingeben.
|
||||||
|
listeEingabe.focus();
|
||||||
|
}
|
||||||
|
if (verworfen > 0) zeigeListenFehler();
|
||||||
|
else versteckeListenFehler();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestaetigt die Erfassung ueber das Formular.
|
||||||
|
*
|
||||||
|
* Ein Numpad auf dem Telefon bestaetigt mit seinem eigenen Schluessel
|
||||||
|
* (Haekchen, "Fertig", "Los"); er loest das submit-Ereignis aus. Hier laufen
|
||||||
|
* deshalb Bestaetigungstaste, eingefuegte Spalte und Klick auf "Hinzufuegen"
|
||||||
|
* zusammen.
|
||||||
|
*
|
||||||
|
* @param {SubmitEvent} ereignis
|
||||||
|
*/
|
||||||
|
function beiListenAbsenden(ereignis) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
// Ein leerer Aufruf ueber die Schaltflaeche springt nicht in die Anzeige:
|
||||||
|
// Wer "Hinzufuegen" tippt, will etwas hinzufuegen. Die Bestaetigungstaste im
|
||||||
|
// leeren Feld bleibt dagegen der schnelle Weg nach vorn.
|
||||||
|
if (ereignis.submitter === listeHinzufuegenKnopf && listeEingabe.value.trim() === '') {
|
||||||
|
listeEingabe.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uebernimmListeneingabe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt einen Eintrag; mit der Tastatur bleibt der Fokus in der Liste.
|
||||||
|
*
|
||||||
|
* @param {number} index
|
||||||
|
* @param {boolean} ueberTastatur
|
||||||
|
*/
|
||||||
|
function entferneListenEintrag(index, ueberTastatur) {
|
||||||
|
if (!Number.isInteger(index) || index < 0 || index >= liste.length) return;
|
||||||
|
const [entfernt] = liste.splice(index, 1);
|
||||||
|
rendereListe();
|
||||||
|
zeigeToast(`Artikelnummer ${entfernt.eingabe} entfernt`);
|
||||||
|
if (ueberTastatur) {
|
||||||
|
const naechster = listeEintraege.querySelector(
|
||||||
|
`.liste-weg[data-index="${String(Math.min(index, liste.length - 1))}"]`,
|
||||||
|
);
|
||||||
|
if (naechster instanceof HTMLElement) {
|
||||||
|
naechster.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listeEingabe.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Richtung des Schritts: der neue Barcode kommt von der Seite.
|
||||||
|
*
|
||||||
|
* @param {number} richtung
|
||||||
|
*/
|
||||||
|
function animiereSchritt(richtung) {
|
||||||
|
listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
|
||||||
|
// Reflow erzwingt den Neustart der Animation beim schnellen Blaettern.
|
||||||
|
void listeMitte.offsetWidth;
|
||||||
|
listeMitte.classList.add(richtung < 0 ? 'liste-schritt-zurueck' : 'liste-schritt-vor');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} animiert
|
||||||
|
* @param {number} [richtung]
|
||||||
|
*/
|
||||||
|
function zeigeListenEintrag(animiert, richtung = 0) {
|
||||||
|
const eintrag = liste[listeIndex];
|
||||||
|
if (!eintrag) return;
|
||||||
|
listeZaehler.textContent = zaehlerText(listeIndex, liste.length);
|
||||||
|
listeGtin.textContent = eintrag.gtin;
|
||||||
|
listeArtikel.textContent = `Artikelnummer ${eintrag.eingabe}`;
|
||||||
|
rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
|
||||||
|
if (animiert) animiereSchritt(richtung);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} index */
|
||||||
|
function starteListenAnzeige(index) {
|
||||||
|
if (listeAnzeigeAktiv || liste.length === 0) return;
|
||||||
|
listeAnzeigeAktiv = true;
|
||||||
|
listeIndex = schritt(index, 0, liste.length);
|
||||||
|
listeErfassen.hidden = true;
|
||||||
|
listeAnzeige.hidden = false;
|
||||||
|
zeigeListenEintrag(false);
|
||||||
|
listeMitte.focus();
|
||||||
|
starteWakeLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} richtung */
|
||||||
|
function blaettere(richtung) {
|
||||||
|
if (!listeAnzeigeAktiv || liste.length === 0) return;
|
||||||
|
listeIndex = schritt(listeIndex, richtung, liste.length);
|
||||||
|
zeigeListenEintrag(true, richtung);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {number} index */
|
||||||
|
function springeZu(index) {
|
||||||
|
if (!listeAnzeigeAktiv || liste.length === 0) return;
|
||||||
|
const neu = schritt(index, 0, liste.length);
|
||||||
|
if (neu === listeIndex) return;
|
||||||
|
const richtung = neu > listeIndex ? 1 : -1;
|
||||||
|
listeIndex = neu;
|
||||||
|
zeigeListenEintrag(true, richtung);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zurueck zum Erfassen: Die Liste bleibt erhalten und wird nur bearbeitet. */
|
||||||
|
function zeigeListeErfassen() {
|
||||||
|
listeAnzeigeAktiv = false;
|
||||||
|
listeAnzeige.hidden = true;
|
||||||
|
listeErfassen.hidden = false;
|
||||||
|
stoppeWakeLock();
|
||||||
|
versteckeListenFehler();
|
||||||
|
listeEingabe.value = '';
|
||||||
|
rendereListe();
|
||||||
|
listeEingabe.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function oeffneListe() {
|
||||||
|
if (listeAktiv) return;
|
||||||
|
listeAktiv = true;
|
||||||
|
listeBereich.hidden = false;
|
||||||
|
document.body.classList.add('liste-offen');
|
||||||
|
// Der Rest der Oberflaeche wird unerreichbar, solange die Liste offen ist.
|
||||||
|
appBereich.toggleAttribute('inert', true);
|
||||||
|
zeigeListeErfassen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliesseListe() {
|
||||||
|
if (!listeAktiv) return;
|
||||||
|
listeAktiv = false;
|
||||||
|
listeAnzeigeAktiv = false;
|
||||||
|
listeBereich.hidden = true;
|
||||||
|
listeErfassen.hidden = false;
|
||||||
|
listeAnzeige.hidden = true;
|
||||||
|
document.body.classList.remove('liste-offen');
|
||||||
|
appBereich.toggleAttribute('inert', false);
|
||||||
|
stoppeWakeLock();
|
||||||
|
listeOeffnenKnopf.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {{ id: number, x: number, y: number } | null} */
|
||||||
|
let geste = null;
|
||||||
|
|
||||||
|
/** @param {PointerEvent} ereignis */
|
||||||
|
function beendeGeste(ereignis) {
|
||||||
|
window.removeEventListener('pointerup', beendeGeste);
|
||||||
|
window.removeEventListener('pointercancel', beendeGeste);
|
||||||
|
const start = geste;
|
||||||
|
geste = null;
|
||||||
|
if (!start || start.id !== ereignis.pointerId || ereignis.type !== 'pointerup') return;
|
||||||
|
const dx = ereignis.clientX - start.x;
|
||||||
|
const dy = ereignis.clientY - start.y;
|
||||||
|
// Erst ab 40 px und nur bei klarer Richtung: sonst waere jeder Tipp ein Blatt.
|
||||||
|
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return;
|
||||||
|
blaettere(dx < 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {PointerEvent} ereignis */
|
||||||
|
function starteGeste(ereignis) {
|
||||||
|
if (!listeAnzeigeAktiv || !ereignis.isPrimary) return;
|
||||||
|
// Ein Zug, der auf einem Pfeil beginnt, ist ein Klick auf den Pfeil.
|
||||||
|
if (ereignis.target instanceof Element && ereignis.target.closest('button')) return;
|
||||||
|
geste = { id: ereignis.pointerId, x: ereignis.clientX, y: ereignis.clientY };
|
||||||
|
window.addEventListener('pointerup', beendeGeste);
|
||||||
|
window.addEventListener('pointercancel', beendeGeste);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Zwischenablage -- */
|
||||||
|
/** @param {string} text @returns {boolean} */
|
||||||
|
function kopiereFallback(text) {
|
||||||
|
const hilfe = document.createElement('textarea');
|
||||||
|
hilfe.value = text;
|
||||||
|
hilfe.setAttribute('readonly', '');
|
||||||
|
hilfe.className = 'kopier-hilfe';
|
||||||
|
document.body.append(hilfe);
|
||||||
|
hilfe.select();
|
||||||
|
let erfolgreich = false;
|
||||||
|
try {
|
||||||
|
erfolgreich = document.execCommand('copy');
|
||||||
|
} catch {
|
||||||
|
erfolgreich = false;
|
||||||
|
}
|
||||||
|
hilfe.remove();
|
||||||
|
return erfolgreich;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kopiereGtin() {
|
||||||
|
if (!aktuell) return;
|
||||||
|
const text = aktuell.gtin;
|
||||||
|
let erfolgreich = false;
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
erfolgreich = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
erfolgreich = false;
|
||||||
|
}
|
||||||
|
if (!erfolgreich) erfolgreich = kopiereFallback(text);
|
||||||
|
zeigeToast(erfolgreich ? 'GTIN kopiert' : 'Kopieren nicht möglich');
|
||||||
|
merkeImVerlauf(aktuell);
|
||||||
|
stoppeVerlaufTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} text */
|
||||||
|
function zeigeToast(text) {
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.hidden = false;
|
||||||
|
if (toastTimer !== undefined) window.clearTimeout(toastTimer);
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toastTimer = undefined;
|
||||||
|
toast.hidden = true;
|
||||||
|
}, TOAST_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ PWA -- */
|
||||||
|
|
||||||
|
function registriereServiceWorker() {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('./sw.js', { scope: './' })
|
||||||
|
.catch((fehler) => window.console.warn('Service Worker nicht registriert.', fehler));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktualisiereOfflineHinweis() {
|
||||||
|
offlineHinweis.hidden = navigator.onLine !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeigeInstallationsmoeglichkeit() {
|
||||||
|
const standalone =
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
/** @type {any} */ (navigator).standalone === true;
|
||||||
|
const istIos =
|
||||||
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
iosHinweis.hidden = !istIos || standalone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Ereignisse -- */
|
||||||
|
|
||||||
|
function verbindeEreignisse() {
|
||||||
|
input.addEventListener('input', beiEingabe);
|
||||||
|
// Fokussieren markiert den gesamten Inhalt (direktes Ueberschreiben), auch
|
||||||
|
// wenn der Browser das Caret nach dem Tippen selbst setzt.
|
||||||
|
input.addEventListener('pointerdown', () => {
|
||||||
|
feldWarFokussiert = document.activeElement === input;
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', markiereEingabe);
|
||||||
|
input.addEventListener('click', () => {
|
||||||
|
if (!feldWarFokussiert) markiereEingabe();
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
feldWarFokussiert = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
gtinKnopf.addEventListener('click', () => void kopiereGtin());
|
||||||
|
kopierenKnopf.addEventListener('click', () => void kopiereGtin());
|
||||||
|
grossKnopf.addEventListener('click', oeffneScan);
|
||||||
|
neuKnopf.addEventListener('click', neueNummer);
|
||||||
|
scanZurueckKnopf.addEventListener('click', schliesseScan);
|
||||||
|
verlaufLoeschenKnopf.addEventListener('click', leereVerlauf);
|
||||||
|
|
||||||
|
listeOeffnenKnopf.addEventListener('click', oeffneListe);
|
||||||
|
listeSchliessenKnopf.addEventListener('click', schliesseListe);
|
||||||
|
listeBeendenKnopf.addEventListener('click', schliesseListe);
|
||||||
|
listeAendernKnopf.addEventListener('click', zeigeListeErfassen);
|
||||||
|
listeWeiterKnopf.addEventListener('click', () => {
|
||||||
|
// Eine noch nicht bestaetigte Nummer wird beim Wechsel uebernommen.
|
||||||
|
if (listeEingabe.value.trim() !== '') {
|
||||||
|
uebernimmListeneingabe();
|
||||||
|
// Eine abgelehnte Nummer haelt die Erfassung offen: Der Fehler muss
|
||||||
|
// gelesen werden koennen, bevor es weitergeht.
|
||||||
|
if (!listeFehler.hidden) return;
|
||||||
|
}
|
||||||
|
// Ein leerer Aufruf mit alter Meldung im Feld raeumt sie weg und startet.
|
||||||
|
versteckeListenFehler();
|
||||||
|
starteListenAnzeige(0);
|
||||||
|
});
|
||||||
|
listeZurueckKnopf.addEventListener('click', () => blaettere(-1));
|
||||||
|
listeVorKnopf.addEventListener('click', () => blaettere(1));
|
||||||
|
listeBuehne.addEventListener('pointerdown', starteGeste);
|
||||||
|
listeMitte.addEventListener('animationend', () => {
|
||||||
|
listeMitte.classList.remove('liste-schritt-vor', 'liste-schritt-zurueck');
|
||||||
|
});
|
||||||
|
listeEingabe.addEventListener('keydown', (ereignis) => {
|
||||||
|
if (!istBestaetigungstaste(ereignis)) return;
|
||||||
|
// Selbst bestaetigen und die Standardaktion unterdruecken: Sonst kaeme
|
||||||
|
// dieselbe Nummer ueber Tastendruck und submit zweimal an.
|
||||||
|
ereignis.preventDefault();
|
||||||
|
uebernimmListeneingabe();
|
||||||
|
});
|
||||||
|
listeForm.addEventListener('submit', beiListenAbsenden);
|
||||||
|
// Die Schaltflaeche darf den Fokus nicht aus dem Feld ziehen: So bleibt die
|
||||||
|
// Bildschirmtastatur zwischen zwei Nummern offen.
|
||||||
|
listeHinzufuegenKnopf.addEventListener('pointerdown', (ereignis) => {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
});
|
||||||
|
listeEingabe.addEventListener('input', () => {
|
||||||
|
if (!listeFehler.hidden) versteckeListenFehler();
|
||||||
|
});
|
||||||
|
listeEintraege.addEventListener('click', (ereignis) => {
|
||||||
|
const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.liste-weg') : null;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
entferneListenEintrag(Number(ziel.dataset.index), ereignis.detail === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
verlaufListe.addEventListener('click', (ereignis) => {
|
||||||
|
const ziel = ereignis.target instanceof Element ? ereignis.target.closest('.verlauf-eintrag') : null;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
const artikel = ziel.dataset.artikel ?? '';
|
||||||
|
if (artikel) {
|
||||||
|
// Mit der Tastatur aktiviert (Enter/Leertaste): Fokus ins Feld, damit
|
||||||
|
// direkt weitergetippt werden kann. Mit dem Finger bleibt der Fokus
|
||||||
|
// ausserhalb, sonst verdeckt die Bildschirmtastatur den Barcode.
|
||||||
|
uebernimmArtikel(artikel, { fokussieren: ereignis.detail === 0 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
installierenKnopf.addEventListener('click', () => {
|
||||||
|
if (!installationsAnfrage) return;
|
||||||
|
installationsAnfrage.prompt();
|
||||||
|
Promise.resolve(installationsAnfrage.userChoice).catch(() => undefined);
|
||||||
|
installationsAnfrage = null;
|
||||||
|
installierenKnopf.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (ereignis) => {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
installationsAnfrage = ereignis;
|
||||||
|
installierenKnopf.hidden = false;
|
||||||
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
installationsAnfrage = null;
|
||||||
|
installierenKnopf.hidden = true;
|
||||||
|
iosHinweis.hidden = true;
|
||||||
|
zeigeToast('App installiert');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('online', aktualisiereOfflineHinweis);
|
||||||
|
window.addEventListener('offline', aktualisiereOfflineHinweis);
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible' && (scanAktiv || listeAnzeigeAktiv)) {
|
||||||
|
starteWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (groessenTimer !== undefined) window.clearTimeout(groessenTimer);
|
||||||
|
groessenTimer = window.setTimeout(() => {
|
||||||
|
groessenTimer = undefined;
|
||||||
|
if (aktuell) {
|
||||||
|
rendereBarcode(barcodeBehaelter, aktuell, 'normal');
|
||||||
|
if (scanAktiv) rendereBarcode(scanBarcodeBehaelter, aktuell, 'scan');
|
||||||
|
}
|
||||||
|
if (listeAnzeigeAktiv) {
|
||||||
|
const eintrag = liste[listeIndex];
|
||||||
|
if (eintrag) rendereBarcode(listeBarcodeBehaelter, eintrag, 'scan');
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (ereignis) => {
|
||||||
|
if (listeAktiv) {
|
||||||
|
if (ereignis.key === 'Escape') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
schliesseListe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!listeAnzeigeAktiv) return;
|
||||||
|
switch (ereignis.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'PageDown':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
blaettere(1);
|
||||||
|
return;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'PageUp':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
blaettere(-1);
|
||||||
|
return;
|
||||||
|
case 'Home':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
springeZu(0);
|
||||||
|
return;
|
||||||
|
case 'End':
|
||||||
|
ereignis.preventDefault();
|
||||||
|
springeZu(liste.length - 1);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scanAktiv) {
|
||||||
|
if (ereignis.key === 'Escape') {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
schliesseScan();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ziel = ereignis.target;
|
||||||
|
const imEingabefeld = ziel === input;
|
||||||
|
// Auf Schaltflaechen und Links gilt die native Tastenaktivierung.
|
||||||
|
const aufBedienelement =
|
||||||
|
ziel instanceof HTMLElement &&
|
||||||
|
!imEingabefeld &&
|
||||||
|
(ziel.tagName === 'BUTTON' || ziel.tagName === 'A' || ziel.isContentEditable);
|
||||||
|
|
||||||
|
if (ereignis.key === 'Enter') {
|
||||||
|
if (imEingabefeld) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
stoppeEingabeTimer();
|
||||||
|
if (!aktuell || aktuell.eingabe !== nurZiffern(input.value)) pruefeUndZeige();
|
||||||
|
if (aktuell) oeffneScan();
|
||||||
|
} else if (!aufBedienelement && aktuell) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
oeffneScan();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ereignis.key === 'Escape') {
|
||||||
|
if (!aufBedienelement && aktuell) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
neueNummer();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ereignis.key === 'Backspace' && !aufBedienelement && !imEingabefeld && aktuell) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
neueNummer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kopiertastenkombination =
|
||||||
|
(ereignis.ctrlKey || ereignis.metaKey) && ereignis.key.toLowerCase() === 'c';
|
||||||
|
if (kopiertastenkombination && aktuell) {
|
||||||
|
const auswahl = window.getSelection()?.toString() ?? '';
|
||||||
|
const eingabeAuswahl =
|
||||||
|
input.selectionStart !== null && input.selectionStart !== input.selectionEnd;
|
||||||
|
if (auswahl === '' && !eingabeAuswahl) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
void kopiereGtin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function starte() {
|
||||||
|
verbindeEreignisse();
|
||||||
|
rendereVerlauf();
|
||||||
|
aktualisiereOfflineHinweis();
|
||||||
|
zeigeInstallationsmoeglichkeit();
|
||||||
|
registriereServiceWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
starte();
|
||||||
343
src/gtin/js/barcode.js
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
/**
|
||||||
|
* EAN-Codierung und SVG-Erzeugung.
|
||||||
|
*
|
||||||
|
* Die Datei ist bewusst frei von DOM-Zugriffen: sie liefert eine reine
|
||||||
|
* Bitfolge (1 = Balken, 0 = Luecke) und daraus ein SVG-Markup. Die
|
||||||
|
* Pruefzifferpruefung aus gtin.js wird vor jeder Codierung wiederholt, damit
|
||||||
|
* kein Barcode fuer eine inkonsistente Nummer entstehen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { gtinGueltig } from './gtin.js';
|
||||||
|
|
||||||
|
/** L-Code (ungerade Paritaet), linke Haelfte. */
|
||||||
|
const L_CODE = [
|
||||||
|
'0001101',
|
||||||
|
'0011001',
|
||||||
|
'0010011',
|
||||||
|
'0111101',
|
||||||
|
'0100011',
|
||||||
|
'0110001',
|
||||||
|
'0101111',
|
||||||
|
'0111011',
|
||||||
|
'0110111',
|
||||||
|
'0001011',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** G-Code (gerade Paritaet), linke Haelfte bei EAN-13. */
|
||||||
|
const G_CODE = [
|
||||||
|
'0100111',
|
||||||
|
'0110011',
|
||||||
|
'0011011',
|
||||||
|
'0100001',
|
||||||
|
'0011101',
|
||||||
|
'0111001',
|
||||||
|
'0000101',
|
||||||
|
'0010001',
|
||||||
|
'0001001',
|
||||||
|
'0010111',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** R-Code, rechte Haelfte (Komplement des L-Codes). */
|
||||||
|
const R_CODE = [
|
||||||
|
'1110010',
|
||||||
|
'1100110',
|
||||||
|
'1101100',
|
||||||
|
'1000010',
|
||||||
|
'1011100',
|
||||||
|
'1001110',
|
||||||
|
'1010000',
|
||||||
|
'1000100',
|
||||||
|
'1001000',
|
||||||
|
'1110100',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paritaetsmuster der linken Gruppe in Abhaengigkeit der ersten Ziffer
|
||||||
|
* (EAN-13). Die erste Ziffer wird selbst nicht als Balken codiert.
|
||||||
|
*/
|
||||||
|
const EAN13_PARITAET = [
|
||||||
|
'LLLLLL',
|
||||||
|
'LLGLGG',
|
||||||
|
'LLGGLG',
|
||||||
|
'LLGGGL',
|
||||||
|
'LGLLGG',
|
||||||
|
'LGGLLG',
|
||||||
|
'LGGGLL',
|
||||||
|
'LGLGLG',
|
||||||
|
'LGLGGL',
|
||||||
|
'LGGLGL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const START_UND_ENDE = '101';
|
||||||
|
const MITTE = '01010';
|
||||||
|
|
||||||
|
/** Ruhezonen in Modulen (links/rechts). */
|
||||||
|
export const QUIETZONE = {
|
||||||
|
'EAN-8': { links: 7, rechts: 7 },
|
||||||
|
'EAN-13': { links: 11, rechts: 7 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Standardhoehe der Balken in Modulen. */
|
||||||
|
export const BALKENHOEHE_MODULE = 62;
|
||||||
|
|
||||||
|
/** Verlaengerung der Rand- und Mittelbalken nach unten, in Modulen. */
|
||||||
|
export const GUARD_ZUSATZ_MODULE = 6;
|
||||||
|
|
||||||
|
/** Reservierte Hoehe fuer die Klartextzeile, in Modulen. */
|
||||||
|
export const TEXT_HOEHE_MODULE = 13;
|
||||||
|
|
||||||
|
/** Schriftgroesse des Klartexts, in Modulen. */
|
||||||
|
export const TEXT_GROESSE_MODULE = 9;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} Textgruppe
|
||||||
|
* @property {string} text anzuzeigende Ziffern
|
||||||
|
* @property {number} start Startposition im Symbol (Modulindex)
|
||||||
|
* @property {number} breite Breite der Gruppe in Modulen
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} EanSymbol
|
||||||
|
* @property {string} gtin validierte GTIN
|
||||||
|
* @property {'EAN-8' | 'EAN-13'} typ Barcode-Typ
|
||||||
|
* @property {string} bits Bitfolge (1 = Balken)
|
||||||
|
* @property {number} module Anzahl der Symbolmodule ohne Ruhezonen
|
||||||
|
* @property {{ links: number, rechts: number }} quietzone Ruhezonen in Modulen
|
||||||
|
* @property {Textgruppe[]} textgruppen Klartext unter dem Symbol
|
||||||
|
* @property {string | null} fuehrendeZiffer erste Ziffer (nur EAN-13)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codiert eine vollstaendige GTIN als EAN-8 oder EAN-13.
|
||||||
|
*
|
||||||
|
* @param {unknown} gtin vollstaendige GTIN (8 oder 13 Ziffern)
|
||||||
|
* @returns {EanSymbol}
|
||||||
|
* @throws {Error} wenn die GTIN ungueltig ist oder die Codierung nicht aufgeht
|
||||||
|
*/
|
||||||
|
export function kodiereEan(gtin) {
|
||||||
|
const wert = String(gtin ?? '');
|
||||||
|
if (!gtinGueltig(wert)) {
|
||||||
|
throw new Error(`Ungültige GTIN: ${wert}`);
|
||||||
|
}
|
||||||
|
const typ = wert.length === 8 ? 'EAN-8' : 'EAN-13';
|
||||||
|
|
||||||
|
/** @type {string} */
|
||||||
|
let links;
|
||||||
|
/** @type {string} */
|
||||||
|
let rechts;
|
||||||
|
/** @type {Textgruppe[]} */
|
||||||
|
let textgruppen;
|
||||||
|
let fuehrendeZiffer = null;
|
||||||
|
|
||||||
|
if (typ === 'EAN-8') {
|
||||||
|
links = '';
|
||||||
|
for (let i = 0; i < 4; i += 1) links += L_CODE[Number(wert[i])];
|
||||||
|
rechts = '';
|
||||||
|
for (let i = 4; i < 8; i += 1) rechts += R_CODE[Number(wert[i])];
|
||||||
|
textgruppen = [
|
||||||
|
{ text: wert.slice(0, 4), start: 3, breite: 28 },
|
||||||
|
{ text: wert.slice(4, 8), start: 36, breite: 28 },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const paritaet = EAN13_PARITAET[Number(wert[0])];
|
||||||
|
links = '';
|
||||||
|
for (let i = 1; i <= 6; i += 1) {
|
||||||
|
const ziffer = Number(wert[i]);
|
||||||
|
links += paritaet[i - 1] === 'L' ? L_CODE[ziffer] : G_CODE[ziffer];
|
||||||
|
}
|
||||||
|
rechts = '';
|
||||||
|
for (let i = 7; i < 13; i += 1) rechts += R_CODE[Number(wert[i])];
|
||||||
|
textgruppen = [
|
||||||
|
{ text: wert.slice(1, 7), start: 3, breite: 42 },
|
||||||
|
{ text: wert.slice(7, 13), start: 50, breite: 42 },
|
||||||
|
];
|
||||||
|
fuehrendeZiffer = wert[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bits = START_UND_ENDE + links + MITTE + rechts + START_UND_ENDE;
|
||||||
|
const erwartet = typ === 'EAN-8' ? 67 : 95;
|
||||||
|
if (bits.length !== erwartet || !/^[01]+$/.test(bits)) {
|
||||||
|
throw new Error(`EAN-Codierung fehlerhaft: ${bits.length} statt ${erwartet} Module.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gtin: wert,
|
||||||
|
typ,
|
||||||
|
bits,
|
||||||
|
module: bits.length,
|
||||||
|
quietzone: QUIETZONE[typ],
|
||||||
|
textgruppen,
|
||||||
|
fuehrendeZiffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groesste ganzzahlige Modulbreite, die in die verfuegbare Breite passt.
|
||||||
|
*
|
||||||
|
* @param {number} verfuegbareBreitePx
|
||||||
|
* @param {number} moduleGesamt Anzahl Module inklusive Ruhezonen
|
||||||
|
* @returns {number} ganzzahlige Modulbreite in Pixeln, mindestens 1
|
||||||
|
*/
|
||||||
|
export function modulbreite(verfuegbareBreitePx, moduleGesamt) {
|
||||||
|
if (!Number.isFinite(verfuegbareBreitePx) || moduleGesamt <= 0) return 1;
|
||||||
|
return Math.max(1, Math.floor(verfuegbareBreitePx / moduleGesamt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximale Balkenhoehe (in Modulen) fuer eine verfuegbare Anzeigehoehe.
|
||||||
|
*
|
||||||
|
* @param {number} verfuegbareHoehePx
|
||||||
|
* @param {number} modulbreitePx
|
||||||
|
* @param {number} [minimum] kleinste sinnvolle Balkenhoehe in Modulen
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function balkenhoeheFuerHoehe(verfuegbareHoehePx, modulbreitePx, minimum = 30) {
|
||||||
|
if (!Number.isFinite(verfuegbareHoehePx) || verfuegbareHoehePx <= 0 || modulbreitePx <= 0) {
|
||||||
|
return BALKENHOEHE_MODULE;
|
||||||
|
}
|
||||||
|
const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||||
|
const moeglich = Math.floor(verfuegbareHoehePx / modulbreitePx) - reserviert;
|
||||||
|
return Math.max(minimum, moeglich);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kleinste sinnvolle Balkenhoehe in Modulen. */
|
||||||
|
export const MIN_BALKENHOEHE_MODULE = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waehlt Modulbreite und Balkenhoehe fuer eine verfuegbare Flaeche.
|
||||||
|
*
|
||||||
|
* Gesucht ist die groesste ganzzahlige Modulbreite, mit der der komplette
|
||||||
|
* Barcode (Balken, Guard-Zusatz und Klartextzeile) sowohl in die Breite als
|
||||||
|
* auch in die Hoehe passt. So fuellt der Barcode die Anzeige bestmoeglich aus,
|
||||||
|
* ohne abgeschnitten zu werden; die Modulbreite bleibt ganzzahlig und damit
|
||||||
|
* gestochen scharf.
|
||||||
|
*
|
||||||
|
* @param {number} verfuegbareBreitePx
|
||||||
|
* @param {number} verfuegbareHoehePx
|
||||||
|
* @param {number} moduleGesamt Anzahl der Module inklusive Ruhezonen
|
||||||
|
* @param {{ minimumBalkenhoehe?: number, maximumBalkenhoehe?: number }} [optionen]
|
||||||
|
* @returns {{ modulbreite: number, balkenhoeheModule: number }}
|
||||||
|
*/
|
||||||
|
export function barcodeGroesse(verfuegbareBreitePx, verfuegbareHoehePx, moduleGesamt, optionen = {}) {
|
||||||
|
const minimum = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(optionen.minimumBalkenhoehe ?? MIN_BALKENHOEHE_MODULE),
|
||||||
|
);
|
||||||
|
const maximum = Math.max(
|
||||||
|
minimum,
|
||||||
|
Math.round(optionen.maximumBalkenhoehe ?? BALKENHOEHE_MODULE),
|
||||||
|
);
|
||||||
|
const reserviert = GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||||
|
const breite =
|
||||||
|
Number.isFinite(verfuegbareBreitePx) && verfuegbareBreitePx > 0
|
||||||
|
? Math.floor(verfuegbareBreitePx)
|
||||||
|
: 320;
|
||||||
|
const hoehe =
|
||||||
|
Number.isFinite(verfuegbareHoehePx) && verfuegbareHoehePx > 0
|
||||||
|
? Math.floor(verfuegbareHoehePx)
|
||||||
|
: 320;
|
||||||
|
const module = Number.isFinite(moduleGesamt) && moduleGesamt > 0 ? Math.floor(moduleGesamt) : 1;
|
||||||
|
|
||||||
|
const groessteBreite = Math.max(1, Math.floor(breite / module));
|
||||||
|
for (let kandidat = groessteBreite; kandidat >= 1; kandidat -= 1) {
|
||||||
|
const platz = Math.floor(hoehe / kandidat) - reserviert;
|
||||||
|
if (kandidat === 1 || platz >= minimum) {
|
||||||
|
return {
|
||||||
|
modulbreite: kandidat,
|
||||||
|
balkenhoeheModule: Math.min(maximum, Math.max(minimum, platz)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { modulbreite: 1, balkenhoeheModule: minimum };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt die Bitfolge in zusammenhaengende Balken.
|
||||||
|
*
|
||||||
|
* @param {string} bits
|
||||||
|
* @returns {{ start: number, breite: number }[]}
|
||||||
|
*/
|
||||||
|
function balkenLaeufe(bits) {
|
||||||
|
/** @type {{ start: number, breite: number }[]} */
|
||||||
|
const laeufe = [];
|
||||||
|
let start = -1;
|
||||||
|
for (let i = 0; i < bits.length; i += 1) {
|
||||||
|
if (bits[i] === '1') {
|
||||||
|
if (start === -1) start = i;
|
||||||
|
} else if (start !== -1) {
|
||||||
|
laeufe.push({ start, breite: i - start });
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start !== -1) laeufe.push({ start, breite: bits.length - start });
|
||||||
|
return laeufe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt das SVG-Markup fuer einen EAN-Barcode.
|
||||||
|
*
|
||||||
|
* Alle Laengen werden in ganzen Modulen gerechnet; die Pixelbreite ergibt sich
|
||||||
|
* aus einem ganzzahligen Vielfachen der Modulbreite. Dadurch entstehen harte
|
||||||
|
* Kanten ohne Teilpixel.
|
||||||
|
*
|
||||||
|
* @param {unknown} gtin vollstaendige GTIN
|
||||||
|
* @param {{ modulbreite?: number, balkenhoeheModule?: number }} [optionen]
|
||||||
|
* @returns {{ markup: string, breite: number, hoehe: number, modulbreite: number, symbol: EanSymbol }}
|
||||||
|
*/
|
||||||
|
export function erzeugeSvgMarkup(gtin, optionen = {}) {
|
||||||
|
const symbol = kodiereEan(gtin);
|
||||||
|
const modulbreitePx = Math.max(1, Math.round(optionen.modulbreite ?? 3));
|
||||||
|
const balkenhoehe = Math.max(
|
||||||
|
20,
|
||||||
|
Math.round(optionen.balkenhoeheModule ?? BALKENHOEHE_MODULE),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { links, rechts } = symbol.quietzone;
|
||||||
|
const gesamtModule = links + symbol.module + rechts;
|
||||||
|
const hoeheModule = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_HOEHE_MODULE;
|
||||||
|
const breitePx = gesamtModule * modulbreitePx;
|
||||||
|
const hoehePx = hoeheModule * modulbreitePx;
|
||||||
|
|
||||||
|
const linkeGruppeEnde = 3 + (symbol.typ === 'EAN-8' ? 28 : 42);
|
||||||
|
const guardBereiche = [
|
||||||
|
{ start: 0, ende: 3 },
|
||||||
|
{ start: linkeGruppeEnde, ende: linkeGruppeEnde + 5 },
|
||||||
|
{ start: symbol.module - 3, ende: symbol.module },
|
||||||
|
];
|
||||||
|
const istGuard = (/** @type {number} */ start, /** @type {number} */ breite) =>
|
||||||
|
guardBereiche.some((bereich) => start >= bereich.start && start + breite <= bereich.ende);
|
||||||
|
|
||||||
|
const rechtecke = balkenLaeufe(symbol.bits)
|
||||||
|
.map(({ start, breite }) => {
|
||||||
|
const hoehe = balkenhoehe + (istGuard(start, breite) ? GUARD_ZUSATZ_MODULE : 0);
|
||||||
|
return `<rect x="${links + start}" y="0" width="${breite}" height="${hoehe}" fill="#000000"/>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const grundlinie = balkenhoehe + GUARD_ZUSATZ_MODULE + TEXT_GROESSE_MODULE;
|
||||||
|
const text = symbol.textgruppen
|
||||||
|
.map(
|
||||||
|
(gruppe) =>
|
||||||
|
`<text x="${links + gruppe.start + gruppe.breite / 2}" y="${grundlinie}" ` +
|
||||||
|
`font-size="${TEXT_GROESSE_MODULE}" text-anchor="middle" ` +
|
||||||
|
`textLength="${gruppe.breite}" lengthAdjust="spacing" fill="#000000">${gruppe.text}</text>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
const fuehrend = symbol.fuehrendeZiffer
|
||||||
|
? `<text x="${links - 4}" y="${grundlinie}" font-size="${TEXT_GROESSE_MODULE}" ` +
|
||||||
|
`text-anchor="middle" fill="#000000">${symbol.fuehrendeZiffer}</text>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const beschriftung = `${symbol.typ}-Barcode ${symbol.gtin}`;
|
||||||
|
const markup =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${breitePx}" height="${hoehePx}" ` +
|
||||||
|
`viewBox="0 0 ${gesamtModule} ${hoeheModule}" preserveAspectRatio="xMidYMid meet" ` +
|
||||||
|
`role="img" aria-label="${beschriftung}" focusable="false">` +
|
||||||
|
`<title>${beschriftung}</title>` +
|
||||||
|
`<rect x="0" y="0" width="${gesamtModule}" height="${hoeheModule}" fill="#ffffff"/>` +
|
||||||
|
`<g shape-rendering="crispEdges">${rechtecke}</g>` +
|
||||||
|
`<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">${fuehrend}${text}</g>` +
|
||||||
|
`</svg>`;
|
||||||
|
|
||||||
|
return { markup, breite: breitePx, hoehe: hoehePx, modulbreite: modulbreitePx, symbol };
|
||||||
|
}
|
||||||
162
src/gtin/js/gtin.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Fachliche Logik des GTIN-Generators.
|
||||||
|
*
|
||||||
|
* Diese Datei enthaelt ausschliesslich die Nummernkreis-Regeln, die
|
||||||
|
* Pruefziffernberechnung und die Validierung. Sie kennt weder DOM noch
|
||||||
|
* Oberflaeche und ist deshalb direkt testbar.
|
||||||
|
*
|
||||||
|
* Regeln (belegte Formate):
|
||||||
|
* 3 Ziffern -> links mit 0 auf 4 Stellen auffuellen -> Praefix 290 -> EAN-8
|
||||||
|
* 4 Ziffern -> Praefix 290 -> EAN-8
|
||||||
|
* 7 Ziffern -> Praefix 20090 -> EAN-13
|
||||||
|
* alles andere ist ungueltig.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @typedef {'EAN-8' | 'EAN-13'} GtinTyp */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} GtinErgebnis
|
||||||
|
* @property {string} eingabe normalisierte Artikelnummer (3, 4 oder 7 Stellen)
|
||||||
|
* @property {string} artikelblock Artikelblock mit fuehrender Null (4 Stellen)
|
||||||
|
* @property {string} praefix Nummernkreis-Praefix
|
||||||
|
* @property {string} basis GTIN ohne Pruefziffer
|
||||||
|
* @property {string} pruefziffer berechnete Pruefziffer
|
||||||
|
* @property {string} gtin vollstaendige GTIN inklusive Pruefziffer
|
||||||
|
* @property {GtinTyp} typ Barcode-Typ
|
||||||
|
* @property {number} laenge GTIN-Laenge (8 oder 13)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Fehlertext fuer die Oberflaeche (bewusst genau ein Text). */
|
||||||
|
export const FEHLERTEXT =
|
||||||
|
'Keine gültige Artikelnummer. Erwartet werden 3, 4 oder 7 Stellen.';
|
||||||
|
|
||||||
|
/** Praefix des kurzen Nummernkreises (EAN-8). */
|
||||||
|
export const PRAEFIX_KURZ = '290';
|
||||||
|
|
||||||
|
/** Praefix des langen Nummernkreises (EAN-13). */
|
||||||
|
export const PRAEFIX_LANG = '20090';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt alles, was keine Ziffer ist. Leere Eingaben werden zu "".
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function nurZiffern(eingabe) {
|
||||||
|
return String(eingabe ?? '').replace(/[^0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet die GTIN-Pruefziffer fuer eine Ziffernfolge ohne Pruefziffer.
|
||||||
|
*
|
||||||
|
* Die Ziffern werden von rechts nach links abwechselnd mit 3 und 1 gewichtet
|
||||||
|
* (rechteste Ziffer mal 3). Danach gilt (10 - (Summe mod 10)) mod 10.
|
||||||
|
*
|
||||||
|
* @param {string} ziffern Ziffernfolge ohne Pruefziffer, mindestens eine Ziffer
|
||||||
|
* @returns {number} Pruefziffer 0-9
|
||||||
|
*/
|
||||||
|
export function berechnePruefziffer(ziffern) {
|
||||||
|
if (typeof ziffern !== 'string' || !/^[0-9]+$/.test(ziffern)) {
|
||||||
|
throw new Error('Prüfziffer kann nur für eine Ziffernfolge berechnet werden.');
|
||||||
|
}
|
||||||
|
let summe = 0;
|
||||||
|
for (let i = 0; i < ziffern.length; i += 1) {
|
||||||
|
const ziffer = Number(ziffern[ziffern.length - 1 - i]);
|
||||||
|
summe += ziffer * (i % 2 === 0 ? 3 : 1);
|
||||||
|
}
|
||||||
|
return (10 - (summe % 10)) % 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft eine vollstaendige GTIN als komplette EAN-Nummer.
|
||||||
|
*
|
||||||
|
* @param {unknown} gtin
|
||||||
|
* @returns {boolean} true, wenn Laenge, Ziffern und Pruefziffer stimmen
|
||||||
|
*/
|
||||||
|
export function gtinGueltig(gtin) {
|
||||||
|
const wert = String(gtin ?? '');
|
||||||
|
if (!/^(?:[0-9]{8}|[0-9]{13})$/.test(wert)) return false;
|
||||||
|
const basis = wert.slice(0, -1);
|
||||||
|
return berechnePruefziffer(basis) === Number(wert.slice(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestimmt den Nummernkreis einer normalisierten Artikelnummer.
|
||||||
|
*
|
||||||
|
* @param {string} ziffern ausschliesslich Ziffern
|
||||||
|
* @returns {{ praefix: string, artikelblock: string, typ: GtinTyp, laenge: number } | null}
|
||||||
|
*/
|
||||||
|
export function bestimmeNummernkreis(ziffern) {
|
||||||
|
if (!/^[0-9]+$/.test(ziffern)) return null;
|
||||||
|
if (ziffern.length === 3) {
|
||||||
|
return {
|
||||||
|
praefix: PRAEFIX_KURZ,
|
||||||
|
artikelblock: ziffern.padStart(4, '0'),
|
||||||
|
typ: 'EAN-8',
|
||||||
|
laenge: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ziffern.length === 4) {
|
||||||
|
return {
|
||||||
|
praefix: PRAEFIX_KURZ,
|
||||||
|
artikelblock: ziffern,
|
||||||
|
typ: 'EAN-8',
|
||||||
|
laenge: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ziffern.length === 7) {
|
||||||
|
return {
|
||||||
|
praefix: PRAEFIX_LANG,
|
||||||
|
artikelblock: ziffern,
|
||||||
|
typ: 'EAN-13',
|
||||||
|
laenge: 13,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand einer Eingabe fuer die Oberflaeche.
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe
|
||||||
|
* @returns {'leer' | 'unvollstaendig' | 'ungueltig' | 'ok'}
|
||||||
|
*/
|
||||||
|
export function eingabeStatus(eingabe) {
|
||||||
|
const ziffern = nurZiffern(eingabe);
|
||||||
|
if (ziffern.length === 0) return 'leer';
|
||||||
|
if (ziffern.length < 3) return 'unvollstaendig';
|
||||||
|
return bestimmeNummernkreis(ziffern) ? 'ok' : 'ungueltig';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt aus einer Artikelnummer die vollstaendige GTIN.
|
||||||
|
*
|
||||||
|
* Die erzeugte GTIN wird anschliessend erneut als komplette EAN validiert
|
||||||
|
* (Sicherheitspruefung). Stimmt etwas nicht, wird null zurueckgegeben und es
|
||||||
|
* darf kein Barcode gerendert werden.
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe Artikelnummer (3, 4 oder 7 Ziffern, Leerzeichen erlaubt)
|
||||||
|
* @returns {GtinErgebnis | null}
|
||||||
|
*/
|
||||||
|
export function erzeugeGtin(eingabe) {
|
||||||
|
const ziffern = nurZiffern(eingabe);
|
||||||
|
const kreis = bestimmeNummernkreis(ziffern);
|
||||||
|
if (!kreis) return null;
|
||||||
|
|
||||||
|
const basis = kreis.praefix + kreis.artikelblock;
|
||||||
|
const pruefziffer = berechnePruefziffer(basis);
|
||||||
|
const gtin = `${basis}${pruefziffer}`;
|
||||||
|
|
||||||
|
if (gtin.length !== kreis.laenge || !gtinGueltig(gtin)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
eingabe: ziffern,
|
||||||
|
artikelblock: kreis.artikelblock,
|
||||||
|
praefix: kreis.praefix,
|
||||||
|
basis,
|
||||||
|
pruefziffer: String(pruefziffer),
|
||||||
|
gtin,
|
||||||
|
typ: kreis.typ,
|
||||||
|
laenge: kreis.laenge,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
src/gtin/js/liste.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Reine Listenlogik des optionalen Listenmodus.
|
||||||
|
*
|
||||||
|
* Die Datei kennt weder DOM noch Oberflaeche: Sie zerlegt eine Eingabe in
|
||||||
|
* Artikelnummern und rechnet die Schritte der Anzeige aus. Damit ist die Logik
|
||||||
|
* unabhaengig von der Oberflaeche testbar, genau wie gtin.js und barcode.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt eine Eingabe in einzelne Zifferngruppen.
|
||||||
|
*
|
||||||
|
* Alles, was keine Ziffer ist, trennt die Gruppen: Leerzeichen, Komma,
|
||||||
|
* Semikolon, Zeilenumbruch und auch Buchstaben. So laesst sich eine ganze
|
||||||
|
* Spalte aus einem Lieferschein in einem Zug einfuegen, ohne dass daraus eine
|
||||||
|
* einzige, viel zu lange Nummer wird.
|
||||||
|
*
|
||||||
|
* @param {unknown} eingabe
|
||||||
|
* @returns {string[]} Zifferngruppen in Eingabereihenfolge, ohne leere Gruppen
|
||||||
|
*/
|
||||||
|
export function zerlegeEingabe(eingabe) {
|
||||||
|
return String(eingabe ?? '')
|
||||||
|
.split(/[^0-9]+/)
|
||||||
|
.filter((gruppe) => gruppe.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naechster Index in der Liste.
|
||||||
|
*
|
||||||
|
* Der Schritt laeuft um: Nach dem letzten Eintrag folgt wieder der erste und
|
||||||
|
* umgekehrt. Damit laesst sich eine Serie ohne Unterbrechung nacheinander
|
||||||
|
* abscannen.
|
||||||
|
*
|
||||||
|
* @param {number} index aktueller Index
|
||||||
|
* @param {number} richtung Schrittweite, z. B. 1 oder -1
|
||||||
|
* @param {number} anzahl Anzahl der Eintraege
|
||||||
|
* @returns {number} Index innerhalb von 0 bis anzahl-1
|
||||||
|
*/
|
||||||
|
export function schritt(index, richtung, anzahl) {
|
||||||
|
if (!Number.isInteger(anzahl) || anzahl <= 0) return 0;
|
||||||
|
const start = Number.isInteger(index) ? index : 0;
|
||||||
|
const schrittweite = Number.isInteger(richtung) ? richtung : 0;
|
||||||
|
return (((start + schrittweite) % anzahl) + anzahl) % anzahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beschriftung des Zaehlers, z. B. "3 / 7".
|
||||||
|
*
|
||||||
|
* @param {number} index aktueller Index
|
||||||
|
* @param {number} anzahl Anzahl der Eintraege
|
||||||
|
* @returns {string} leer, wenn die Liste leer ist
|
||||||
|
*/
|
||||||
|
export function zaehlerText(index, anzahl) {
|
||||||
|
if (!Number.isInteger(anzahl) || anzahl <= 0) return '';
|
||||||
|
const position = (Number.isInteger(index) ? index : 0) + 1;
|
||||||
|
return `${Math.min(Math.max(position, 1), anzahl)} / ${anzahl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt die Bestaetigungstaste einer Tastatur.
|
||||||
|
*
|
||||||
|
* Ein Numpad auf dem Telefon hat keine Eingabetaste: Es bestaetigt mit einem
|
||||||
|
* eigenen Schluessel (Haekchen, "Fertig", "Los"). Je nach Tastatur meldet der
|
||||||
|
* Browser ihn als `key: "Enter"` oder nur ueber den alten `keyCode` 13, in
|
||||||
|
* manchen Faellen mit `key: "Unidentified"`. Beide Kennungen muessen gelten,
|
||||||
|
* sonst bleibt die eingegebene Nummer auf dem Telefon liegen.
|
||||||
|
*
|
||||||
|
* @param {{ key?: unknown, keyCode?: unknown } | null | undefined} ereignis Tastenereignis
|
||||||
|
* @returns {boolean} true, wenn die Taste die Eingabe bestaetigt
|
||||||
|
*/
|
||||||
|
export function istBestaetigungstaste(ereignis) {
|
||||||
|
if (!ereignis) return false;
|
||||||
|
const taste = typeof ereignis.key === 'string' ? ereignis.key : '';
|
||||||
|
const kennung = typeof ereignis.keyCode === 'number' ? ereignis.keyCode : 0;
|
||||||
|
return taste === 'Enter' || kennung === 13;
|
||||||
|
}
|
||||||
34
src/gtin/manifest.webmanifest
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "GTIN Generator",
|
||||||
|
"short_name": "GTIN",
|
||||||
|
"description": "Artikelnummer eingeben, GTIN erzeugen und den EAN-Barcode direkt vom Bildschirm scannen – offline, ohne Anmeldung.",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "./",
|
||||||
|
"scope": "./",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"categories": ["business", "productivity", "utilities"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "./icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "./icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
173
src/gtin/sw.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/* Service Worker des GTIN-Generators.
|
||||||
|
*
|
||||||
|
* Nach dem ersten Laden liegen HTML, CSS, JavaScript, Manifest und Icons im
|
||||||
|
* Cache. Danach funktioniert die Anwendung ohne WLAN, ohne Mobilfunk und ohne
|
||||||
|
* erreichbaren Server.
|
||||||
|
*
|
||||||
|
* CACHE_VERSION wird beim Build aus dem Inhalt der Anwendungsdateien ersetzt.
|
||||||
|
* Dadurch erkennt der Browser ein neues Release und raeumt den alten Cache auf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const sw = /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (self));
|
||||||
|
|
||||||
|
const CACHE_VERSION = '15d23ab0263e';
|
||||||
|
const CACHE_PREFIX = 'desfoto-gtin-generator-';
|
||||||
|
const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// PRECACHE
|
||||||
|
const PRECACHE = [
|
||||||
|
'./',
|
||||||
|
'./index.html',
|
||||||
|
'./css/app.css',
|
||||||
|
'./js/app.js',
|
||||||
|
'./js/gtin.js',
|
||||||
|
'./js/barcode.js',
|
||||||
|
'./js/liste.js',
|
||||||
|
'./manifest.webmanifest',
|
||||||
|
'./icons/favicon.svg',
|
||||||
|
'./icons/icon-192.png',
|
||||||
|
'./icons/icon-512.png',
|
||||||
|
'./icons/icon-maskable-512.png',
|
||||||
|
];
|
||||||
|
// END PRECACHE
|
||||||
|
|
||||||
|
/** @param {string} pfad */
|
||||||
|
function scopeUrl(pfad) {
|
||||||
|
return new URL(pfad, sw.registration.scope).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDEX_URL = scopeUrl('./index.html');
|
||||||
|
|
||||||
|
/** Pfad des Geltungsbereichs, z. B. "/gtin/". */
|
||||||
|
const SCOPE_PFAD = new URL(sw.registration.scope).pathname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kennung der ausgelieferten Anwendungsquelle. Nur eine Antwort, die diese
|
||||||
|
* Kennung enthaelt, ist die echte index.html dieses Projekts.
|
||||||
|
*/
|
||||||
|
const SHELL_KENNUNG = 'id="artikelnummer"';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entscheidet, ob eine Antwort die vorgehaltene Anwendungshuelle ersetzen darf.
|
||||||
|
*
|
||||||
|
* Nur eine erfolgreiche HTML-Antwort der eigenen Anwendung ist zulaessig. Eine
|
||||||
|
* Anmeldeseite der Steuerungsebene, eine Magic-Link-Seite oder eine Fehlerseite
|
||||||
|
* (z. B. "App deployment in progress") wuerde die Anwendung sonst offline
|
||||||
|
* unbrauchbar machen, weil sie die echte Seite aus dem Cache verdraengt.
|
||||||
|
*
|
||||||
|
* @param {Response} antwort
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function istAnwendungshuelle(antwort) {
|
||||||
|
if (!antwort.ok || antwort.type !== 'basic') return false;
|
||||||
|
const inhaltstyp = antwort.headers.get('content-type') ?? '';
|
||||||
|
if (!inhaltstyp.includes('text/html')) return false;
|
||||||
|
const endgueltig = new URL(antwort.url);
|
||||||
|
if (endgueltig.origin !== sw.location.origin) return false;
|
||||||
|
if (!endgueltig.pathname.startsWith(SCOPE_PFAD)) return false;
|
||||||
|
try {
|
||||||
|
return (await antwort.text()).includes(SHELL_KENNUNG);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.addEventListener('install', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) =>
|
||||||
|
cache.addAll(
|
||||||
|
PRECACHE.map((pfad) => new Request(scopeUrl(pfad), { cache: 'reload' })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => sw.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sw.addEventListener('activate', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((namen) =>
|
||||||
|
Promise.all(
|
||||||
|
namen
|
||||||
|
.filter((name) => name.startsWith(CACHE_PREFIX) && name !== CACHE_NAME)
|
||||||
|
.map((name) => caches.delete(name)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => sw.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bedient einen Navigationsaufruf.
|
||||||
|
*
|
||||||
|
* Bewusst aus dem Cache: Nur so stammen HTML und Assets immer aus derselben
|
||||||
|
* Cache-Generation. Der neue Service Worker uebernimmt dank
|
||||||
|
* skipWaiting()/clients.claim() sofort; ein neues Release ist damit beim
|
||||||
|
* naechsten Laden konsistent aktiv (kein "neues HTML, altes JavaScript").
|
||||||
|
*
|
||||||
|
* @param {FetchEvent} ereignis
|
||||||
|
* @param {Request} anfrage
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
function bedieneNavigation(ereignis, anfrage) {
|
||||||
|
return caches.match(INDEX_URL).then((treffer) => {
|
||||||
|
if (treffer) return treffer;
|
||||||
|
return fetch(anfrage)
|
||||||
|
.then((antwort) => {
|
||||||
|
const pruefung = antwort.clone();
|
||||||
|
const kopie = antwort.clone();
|
||||||
|
// waitUntil haelt die Cache-Schreiboperation am Leben. Ein blosses
|
||||||
|
// "fire and forget" innerhalb von respondWith kann abgebrochen werden.
|
||||||
|
ereignis.waitUntil(
|
||||||
|
istAnwendungshuelle(pruefung)
|
||||||
|
.then((istHuelle) =>
|
||||||
|
istHuelle
|
||||||
|
? caches.open(CACHE_NAME).then((cache) => cache.put(INDEX_URL, kopie))
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
.catch(() => undefined),
|
||||||
|
);
|
||||||
|
return antwort;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(INDEX_URL).then((treffer) => treffer ?? Response.error()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.addEventListener('fetch', (ereignis) => {
|
||||||
|
const anfrage = ereignis.request;
|
||||||
|
if (anfrage.method !== 'GET') return;
|
||||||
|
|
||||||
|
const ziel = new URL(anfrage.url);
|
||||||
|
if (ziel.origin !== sw.location.origin) return;
|
||||||
|
if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;
|
||||||
|
if (ziel.pathname.endsWith('/health')) return;
|
||||||
|
|
||||||
|
if (anfrage.mode === 'navigate') {
|
||||||
|
const appStart = new URL('./', sw.registration.scope).pathname;
|
||||||
|
const appIndex = new URL('./index.html', sw.registration.scope).pathname;
|
||||||
|
if (ziel.pathname !== appStart && ziel.pathname !== appIndex) return;
|
||||||
|
ereignis.respondWith(bedieneNavigation(ereignis, anfrage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ereignis.respondWith(
|
||||||
|
caches.match(anfrage).then((treffer) => {
|
||||||
|
if (treffer) return treffer;
|
||||||
|
return fetch(anfrage).then((antwort) => {
|
||||||
|
if (antwort.ok && antwort.type === 'basic') {
|
||||||
|
const kopie = antwort.clone();
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.put(anfrage, kopie))
|
||||||
|
.catch(() => undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return antwort;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
10
src/pages.py
@@ -1749,7 +1749,8 @@ def impressum() -> str:
|
|||||||
|
|
||||||
<h2>Marke und Angebot</h2>
|
<h2>Marke und Angebot</h2>
|
||||||
<p><strong>desfoto</strong> ist der Markenauftritt von {e(LEGAL['business'])}. Alle über diese
|
<p><strong>desfoto</strong> ist der Markenauftritt von {e(LEGAL['business'])}. Alle über diese
|
||||||
Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht.</p>
|
Website erreichbaren Leistungen werden von {e(LEGAL['business'])} erbracht. Das kostenlose
|
||||||
|
GTIN-Tool ist ein browserbasiertes Zusatzwerkzeug desselben Anbieters.</p>
|
||||||
|
|
||||||
<h2>Urheberrecht und Bildrechte</h2>
|
<h2>Urheberrecht und Bildrechte</h2>
|
||||||
<p>Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.
|
<p>Alle auf dieser Website gezeigten Fotografien und Filme sind urheberrechtlich geschützt.
|
||||||
@@ -1829,8 +1830,11 @@ def datenschutz() -> str:
|
|||||||
|
|
||||||
<h2>5. Cookies und lokale Speicherung</h2>
|
<h2>5. Cookies und lokale Speicherung</h2>
|
||||||
<p>Diese Website setzt <strong>keine Cookies</strong> und verwendet keine Analyse-, Marketing- oder
|
<p>Diese Website setzt <strong>keine Cookies</strong> und verwendet keine Analyse-, Marketing- oder
|
||||||
Tracking-Technologien. Es werden auch keine Inhalte im lokalen Speicher deines Browsers abgelegt.
|
Tracking-Technologien. Das browserbasierte GTIN-Tool speichert die letzten bis zu 20
|
||||||
Ein Cookie-Banner ist deshalb nicht erforderlich.</p>
|
Artikelnummern und erzeugten GTINs im lokalen Speicher deines Browsers, damit du sie dort erneut
|
||||||
|
aufrufen oder den Verlauf löschen kannst. Die Einträge werden nicht an einen Server übertragen.
|
||||||
|
Für den Offlinebetrieb speichert dein Browser außerdem die Anwendungsdateien im Cache. Beides
|
||||||
|
kannst du über das Tool beziehungsweise die Browser-Einstellungen löschen.</p>
|
||||||
|
|
||||||
<h2>6. Schriftarten und Bilder</h2>
|
<h2>6. Schriftarten und Bilder</h2>
|
||||||
<p>Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.
|
<p>Die verwendeten Schriftarten werden von diesem Server ausgeliefert und sind lokal eingebunden.
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import pages # noqa: E402
|
|||||||
from content import CONTACT, LEGAL, SITE as META # noqa: E402
|
from content import CONTACT, LEGAL, SITE as META # noqa: E402
|
||||||
|
|
||||||
ROUTE_PATHS = [route[0] for route in pages.ROUTES]
|
ROUTE_PATHS = [route[0] for route in pages.ROUTES]
|
||||||
HTML_FILES = sorted(SITE.rglob("*.html"))
|
HTML_FILES = [SITE / route[1] for route in pages.ROUTES] + [SITE / "404.html"]
|
||||||
|
GTIN_HTML = SITE / "gtin" / "index.html"
|
||||||
|
|
||||||
|
|
||||||
def read(path: Path) -> str:
|
def read(path: Path) -> str:
|
||||||
@@ -48,8 +49,8 @@ class BuildLayout(unittest.TestCase):
|
|||||||
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
|
self.assertTrue((SITE / rel).is_file(), f"missing {rel}")
|
||||||
|
|
||||||
def test_no_stray_html_files(self):
|
def test_no_stray_html_files(self):
|
||||||
expected = {route[1] for route in pages.ROUTES} | {"404.html"}
|
expected = {route[1] for route in pages.ROUTES} | {"404.html", "gtin/index.html"}
|
||||||
actual = {str(f.relative_to(SITE)) for f in HTML_FILES}
|
actual = {str(f.relative_to(SITE)) for f in SITE.rglob("*.html")}
|
||||||
self.assertEqual(expected, actual)
|
self.assertEqual(expected, actual)
|
||||||
|
|
||||||
def test_site_files_are_world_readable(self):
|
def test_site_files_are_world_readable(self):
|
||||||
@@ -214,6 +215,9 @@ class PrivacyAndLegal(unittest.TestCase):
|
|||||||
for needle in (
|
for needle in (
|
||||||
"keine Zugriffsprotokolle",
|
"keine Zugriffsprotokolle",
|
||||||
"keine Cookies",
|
"keine Cookies",
|
||||||
|
"GTIN-Tool",
|
||||||
|
"bis zu 20",
|
||||||
|
"nicht an einen Server übertragen",
|
||||||
"youtube-nocookie.com",
|
"youtube-nocookie.com",
|
||||||
"Art. 6 Abs. 1 lit. f DSGVO",
|
"Art. 6 Abs. 1 lit. f DSGVO",
|
||||||
LEGAL["authority"],
|
LEGAL["authority"],
|
||||||
@@ -314,6 +318,43 @@ class Content(unittest.TestCase):
|
|||||||
self.assertIn("User-agent: *", read(SITE / "robots.txt"))
|
self.assertIn("User-agent: *", read(SITE / "robots.txt"))
|
||||||
self.assertIn(f"Sitemap: {META['url']}/sitemap.xml", read(SITE / "robots.txt"))
|
self.assertIn(f"Sitemap: {META['url']}/sitemap.xml", read(SITE / "robots.txt"))
|
||||||
|
|
||||||
|
def test_gtin_tool_is_public_but_unlisted(self):
|
||||||
|
text = read(GTIN_HTML)
|
||||||
|
self.assertIn("GTIN Generator", text)
|
||||||
|
self.assertIn('<link rel="canonical" href="https://desfoto.de/gtin/" />', text)
|
||||||
|
self.assertIn('<meta name="robots" content="noindex, nofollow" />', text)
|
||||||
|
self.assertIn('href="/impressum/"', text)
|
||||||
|
self.assertIn('href="/datenschutz/"', text)
|
||||||
|
self.assertNotIn('href="/"', text)
|
||||||
|
self.assertNotIn("/gtin/", read(SITE / "sitemap.xml"))
|
||||||
|
for file in HTML_FILES:
|
||||||
|
with self.subTest(file=str(file.relative_to(SITE))):
|
||||||
|
self.assertNotIn("/gtin", read(file))
|
||||||
|
|
||||||
|
def test_gtin_assets_and_local_data_storage_are_scoped(self):
|
||||||
|
html_text = read(GTIN_HTML)
|
||||||
|
for asset in ("./css/app.css", "./js/app.js", "./manifest.webmanifest"):
|
||||||
|
with self.subTest(asset=asset):
|
||||||
|
self.assertTrue((SITE / "gtin" / asset.removeprefix("./")).is_file())
|
||||||
|
self.assertIn(asset, html_text)
|
||||||
|
self.assertTrue((SITE / "gtin" / "sw.js").is_file())
|
||||||
|
self.assertFalse((SITE / "gtin" / "health").exists())
|
||||||
|
app_js = read(SITE / "gtin" / "js" / "app.js")
|
||||||
|
self.assertIn("desfoto.gtin.verlauf.v1", app_js)
|
||||||
|
self.assertIn("localStorage", app_js)
|
||||||
|
self.assertIn(".register('./sw.js', { scope: './' })", app_js)
|
||||||
|
self.assertNotIn("fetch(", app_js)
|
||||||
|
self.assertNotIn("XMLHttpRequest", app_js)
|
||||||
|
service_worker = read(SITE / "gtin" / "sw.js")
|
||||||
|
self.assertIn("name.startsWith(CACHE_PREFIX)", service_worker)
|
||||||
|
self.assertIn("if (!ziel.pathname.startsWith(SCOPE_PFAD)) return;", service_worker)
|
||||||
|
self.assertNotIn("/impressum/", service_worker)
|
||||||
|
|
||||||
|
def test_gtin_manifest_uses_the_web_app_manifest_mime_type(self):
|
||||||
|
conf = read(ROOT / "nginx.conf")
|
||||||
|
self.assertIn("location = /gtin/manifest.webmanifest", conf)
|
||||||
|
self.assertIn("default_type application/manifest+json;", conf)
|
||||||
|
|
||||||
|
|
||||||
class Redesign(unittest.TestCase):
|
class Redesign(unittest.TestCase):
|
||||||
"""The 2026 redesign: Arbeitsbereiche, real portfolio, logo, motion layer."""
|
"""The 2026 redesign: Arbeitsbereiche, real portfolio, logo, motion layer."""
|
||||||
|
|||||||