commit 8491a6daa0327d47a1cf46e8380d880f3ce69fd3 Author: flooxik Date: Sat Oct 24 16:53:46 2026 +0300 v0.5.1: Первая версия diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8a044d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/* +!/.gitignore +!/index.html +!/api.php +!/manifest.webmanifest +!/icons/ diff --git a/api.php b/api.php new file mode 100644 index 0000000..b25d132 --- /dev/null +++ b/api.php @@ -0,0 +1,424 @@ +getCode() ?: 400) : 500); + echo json_encode(['error' => $e instanceof ApiError ? $e->getMessage() : 'Ошибка сервера (подробности в логе PHP)'], JSON_UNESCAPED_UNICODE); +}); + +function out($d, int $c = 200): void { http_response_code($c); echo json_encode($d, JSON_UNESCAPED_UNICODE); exit; } +function rd(string $f, $def) { $p = D . $f; if (!is_file($p)) return $def; $j = json_decode((string)file_get_contents($p), true); return $j === null ? $def : $j; } +function wr(string $f, $d): void { + $p = D . $f; $t = $p . '.tmp'; + file_put_contents($t, json_encode($d, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + rename($t, $p); +} +function str($v, int $max): string { return is_scalar($v) ? mb_substr(trim((string)$v), 0, $max) : ''; } +function new_id(string $p = ''): string { return $p . bin2hex(random_bytes(5)); } + +function load_ref(): array { + $r = rd('ref.json', []); + if (!isset($r['dicts'])) { + $r = ['dicts' => [ + ['id' => 'cats', 'name' => 'Категории', 'values' => array_values($r['cats'] ?? ['Категория 1', 'Категория 2', 'Категория 3'])], + ['id' => 'locs', 'name' => 'Расположения', 'values' => array_values($r['locs'] ?? ['Склад', 'Дом', 'Воронеж'])], + ], 'columns' => []]; + } + $r['columns'] = $r['columns'] ?? []; + if (dict_idx($r, 'ltypes') < 0) $r['dicts'][] = ['id' => 'ltypes', 'name' => 'Типы списков', 'values' => LIST_TYPES]; + return $r; +} +function std_cols(): array { + return [ + ['id' => 'name', 'name' => 'Название', 'type' => 'text', 'dict' => null], + ['id' => 'qty', 'name' => 'Кол-во', 'type' => 'number', 'dict' => null], + ['id' => 'cat', 'name' => 'Категория', 'type' => 'select', 'dict' => 'cats'], + ['id' => 'loc', 'name' => 'Расположение', 'type' => 'select', 'dict' => 'locs'], + ]; +} +function cols_map(array $ref): array { + $m = []; + foreach (array_merge(std_cols(), $ref['columns']) as $c) $m[$c['id']] = $c; + return $m; +} +function ltypes(array $ref): array { $i = dict_idx($ref, 'ltypes'); return $i < 0 ? LIST_TYPES : $ref['dicts'][$i]['values']; } +function ref_values(array $ref, string $id): array { $i = dict_idx($ref, $id); return $i < 0 ? [] : $ref['dicts'][$i]['values']; } +function dict_idx(array $ref, string $id): int { + foreach ($ref['dicts'] as $i => $d) if ($d['id'] === $id) return $i; + return -1; +} + +function norm_list(array $l): array { + if (!isset($l['kind'])) { + $l['kind'] = 'standard'; + $l['cols'] = STD_IDS; + $l['ltype'] = (($l['type'] ?? '') === 'box') ? 'Коробка' : ''; + $items = []; + foreach ($l['items'] ?? [] as $it) { + $items[] = ['id' => (string)($it['id'] ?? new_id()), 'v' => [ + 'name' => (string)($it['name'] ?? ''), 'qty' => (string)($it['qty'] ?? ''), + 'cat' => (string)($it['cat'] ?? ''), 'loc' => (string)($it['loc'] ?? '')]]; + } + $l['items'] = $items; + unset($l['type']); + } + $l['ltype'] = $l['ltype'] ?? ''; + return $l; +} +function load_lists(): array { return array_map('norm_list', rd('lists.json', [])); } +function list_idx(array $lists, string $id): int { + foreach ($lists as $i => $l) if ($l['id'] === $id) return $i; + return -1; +} +function same_cols(array $a, array $b): bool { + $a = $a['cols']; $b = $b['cols']; sort($a); sort($b); + return $a === $b; +} + +function map_items(array $items, array $dst, array $ref): array { + $out = []; + foreach ($items as $it) { + $v = []; + foreach ($dst['cols'] as $c) $v[$c] = (string)($it['v'][$c] ?? ''); + if ($dst['kind'] === 'standard') { + if ($v['name'] === '') $v['name'] = 'Без названия'; + if ($v['cat'] === '') $v['cat'] = (string)(ref_values($ref, 'cats')[0] ?? ''); + if ($v['loc'] === '') $v['loc'] = (string)(ref_values($ref, 'locs')[0] ?? ''); + } + $out[] = ['id' => new_id(), 'v' => $v]; + } + return $out; +} +function clean_val(array $col, $v, array $ref): string { + $v = is_scalar($v) ? trim((string)$v) : ''; + switch ($col['type']) { + case 'text': + return mb_substr($v, 0, 200); + case 'number': + if ($v === '') return ''; + if (!is_numeric($v) || strlen($v) > 20) fail('«' . $col['name'] . '»: нужно число'); + return $v; + case 'date': + if ($v === '') return ''; + if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $v, $m) || !checkdate((int)$m[2], (int)$m[3], (int)$m[1])) fail('«' . $col['name'] . '»: неверная дата'); + return $v; + case 'check': + return ($v === '1' || $v === 'true') ? '1' : ''; + case 'select': + if ($v === '') return ''; + $di = dict_idx($ref, (string)$col['dict']); + if ($di < 0 || !in_array($v, $ref['dicts'][$di]['values'], true)) fail('«' . $col['name'] . '»: значения «' . mb_substr($v, 0, 40) . '» нет в справочнике'); + return $v; + } + return ''; +} +function clean_row($raw, array $cols, array $cm, array $ref, string $kind): array { + $raw = is_array($raw) ? $raw : []; + $v = []; $any = false; + foreach ($cols as $c) { + $v[$c] = clean_val($cm[$c], $raw[$c] ?? '', $ref); + if ($v[$c] !== '') $any = true; + } + if ($kind === 'standard') { + if ($v['name'] === '') fail('У предмета должно быть название'); + if ($v['cat'] === '' || $v['loc'] === '') fail('Укажите категорию и расположение'); + } elseif (!$any) fail('Строка пустая'); + return $v; +} +function clean_list(array $l, array $ref, ?array $old): array { + $id = (string)($l['id'] ?? ''); + if (!preg_match('/^[a-z0-9]{3,20}$/', $id)) fail('Некорректный список'); + $cm = cols_map($ref); + $lt = str($l['ltype'] ?? '', 60); + $oldLt = $old ? (string)($old['ltype'] ?? '') : ''; + $ltype = ($lt === '' || in_array($lt, ltypes($ref), true) || $lt === $oldLt) ? $lt : $oldLt; + if ($old) { + $kind = $old['kind']; $cols = $old['cols']; + } else { + $kind = (($l['kind'] ?? '') === 'custom') ? 'custom' : 'standard'; + if ($kind === 'standard') $cols = STD_IDS; + else { + $cols = array_values(array_unique(array_map('strval', (array)($l['cols'] ?? [])))); + if (!$cols || count($cols) > 12) fail('Выберите от 1 до 12 колонок'); + foreach ($cols as $c) if (!isset($cm[$c])) fail('Неизвестная колонка'); + } + } + $src = $l['items'] ?? null; + if (!is_array($src) || count($src) > 5000) fail('Некорректный список предметов'); + $items = []; + foreach ($src as $it) { + $iid = substr(preg_replace('/[^a-z0-9]/', '', strtolower((string)($it['id'] ?? ''))), 0, 20); + $items[] = ['id' => $iid !== '' ? $iid : new_id(), 'v' => clean_row($it['v'] ?? [], $cols, $cm, $ref, $kind)]; + } + return ['id' => $id, 'name' => str($l['name'] ?? '', 100) ?: 'Список', 'kind' => $kind, 'ltype' => $ltype, + 'cols' => $cols, 'final' => !empty($l['final']), 'items' => $items]; +} + +function touch_presence(string $u): void { + $pr = rd('presence.json', []); $now = time(); + if (($pr[$u] ?? 0) > $now - 15) return; + $pr[$u] = $now; + foreach ($pr as $k => $t) if ($t < $now - 86400) unset($pr[$k]); + wr('presence.json', $pr); +} + +function session_info(): array { + return ['user' => ['login' => $_SESSION['u'], 'role' => $_SESSION['role']], 'csrf' => $_SESSION['csrf'], 'mustChange' => !empty($_SESSION['must'])]; +} + +session_name('inv'); +session_set_cookie_params(['httponly' => true, 'samesite' => 'Strict', 'secure' => !empty($_SERVER['HTTPS'])]); +session_start(); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') out(['error' => 'POST only'], 405); +$in = json_decode((string)file_get_contents('php://input'), true) ?: []; +$a = (string)($in['a'] ?? ''); + +$lock = fopen(D . '.lock', 'c'); flock($lock, LOCK_EX); + +if ($a === 'login') { + $login = mb_strtolower(str($in['login'] ?? '', 50)); + $ip = $_SERVER['REMOTE_ADDR'] ?? '?'; + $buckets = [[hash('sha256', "$ip|$login"), 5], [hash('sha256', $ip), 20]]; + $at = rd('attempts.json', []); $now = time(); + foreach ($at as $k => $v) if (($v['t'] ?? 0) < $now - 3600 && ($v['until'] ?? 0) < $now) unset($at[$k]); + foreach ($buckets as [$k]) if (($at[$k]['until'] ?? 0) > $now) { wr('attempts.json', $at); out(['error' => 'Слишком много попыток. Подождите несколько минут.'], 429); } + $users = rd('users.json', ['users' => []]); $u = null; $ui = -1; + foreach ($users['users'] as $i => $x) if ($x['login'] === $login) { $u = $x; $ui = $i; } + $pw = (string)($in['password'] ?? ''); + $ok = password_verify($pw, $u['hash'] ?? password_hash('dummy', PASSWORD_DEFAULT)) && $u !== null; + if (!$ok) { + foreach ($buckets as [$k, $lim]) { + $n = ($at[$k]['n'] ?? 0) + 1; + $at[$k] = ['n' => $n, 't' => $now, 'until' => $n >= $lim ? $now + 600 : 0]; + if ($n >= $lim) $at[$k]['n'] = 0; + } + wr('attempts.json', $at); usleep(500000); + out(['error' => 'Неверный логин или пароль'], 401); + } + foreach ($buckets as [$k]) unset($at[$k]); + wr('attempts.json', $at); + if (password_needs_rehash($u['hash'], PASSWORD_DEFAULT)) { $users['users'][$ui]['hash'] = password_hash($pw, PASSWORD_DEFAULT); wr('users.json', $users); } + session_regenerate_id(true); + $_SESSION = ['u' => $u['login'], 'role' => $u['role'], 'csrf' => bin2hex(random_bytes(16)), 'must' => !empty($u['must_change'])]; + touch_presence($u['login']); + out(session_info()); +} + +if (empty($_SESSION['u'])) out(['error' => 'auth'], 401); +touch_presence((string)$_SESSION['u']); +if ($a === 'me') out(session_info()); +if (!hash_equals($_SESSION['csrf'], (string)($_SERVER['HTTP_X_CSRF'] ?? ''))) out(['error' => 'CSRF'], 403); +$admin = $_SESSION['role'] === 'admin'; +if (!empty($_SESSION['must']) && !in_array($a, ['logout', 'passwd'], true)) fail('Сначала смените пароль', 403); +$ref = load_ref(); + +switch ($a) { +case 'ping': + out(['ok' => true]); + +case 'logout': + $pr = rd('presence.json', []); unset($pr[$_SESSION['u']]); wr('presence.json', $pr); + $_SESSION = []; session_destroy(); out(['ok' => true]); + +case 'passwd': + $users = rd('users.json', ['users' => []]); + $new = (string)($in['new'] ?? ''); + $oldPw = (string)($in['old'] ?? ''); + if (mb_strlen($new) < 8) fail('Новый пароль — не короче 8 символов'); + if ($new === $oldPw) fail('Новый пароль должен отличаться от текущего'); + foreach ($users['users'] as $i => $x) if ($x['login'] === $_SESSION['u']) { + if (!password_verify($oldPw, $x['hash'])) fail('Неверный текущий пароль', 403); + $users['users'][$i]['hash'] = password_hash($new, PASSWORD_DEFAULT); + unset($users['users'][$i]['must_change']); + wr('users.json', $users); $_SESSION['must'] = false; out(['ok' => true]); + } + fail('Пользователь не найден', 404); + +case 'state': + $r = ['ref' => $ref, 'std' => std_cols(), 'listTypes' => ltypes($ref)]; + if (!$admin) $r['lists'] = load_lists(); + out($r); + +case 'users': + if (!$admin) fail('forbidden', 403); + $pr = rd('presence.json', []); $now = time(); $res = []; + foreach (rd('users.json', ['users' => []])['users'] as $x) + $res[] = ['login' => $x['login'], 'role' => $x['role'] ?? 'user', 'online' => ($pr[$x['login']] ?? 0) > $now - ONLINE_SEC]; + out(['users' => $res]); + +case 'user_add': + if (!$admin) fail('forbidden', 403); + $nl = mb_strtolower(str($in['login'] ?? '', 30)); $np = (string)($in['password'] ?? ''); + if (!preg_match('/^[a-z0-9_.-]{2,30}$/', $nl)) fail('Логин: 2–30 символов, латинские буквы, цифры и знаки _ . -'); + if (mb_strlen($np) < 8) fail('Пароль — не короче 8 символов'); + $users = rd('users.json', ['users' => []]); + foreach ($users['users'] as $x) if ($x['login'] === $nl) fail('Такой логин уже есть'); + if (count($users['users']) >= 50) fail('Слишком много пользователей'); + $users['users'][] = ['login' => $nl, 'role' => 'user', 'hash' => password_hash($np, PASSWORD_DEFAULT)]; + wr('users.json', $users); out(['ok' => true]); + +case 'list_save': + if ($admin) fail('forbidden', 403); + $lists = load_lists(); + $id = (string)($in['list']['id'] ?? ''); + $i = list_idx($lists, $id); + $new = clean_list((array)($in['list'] ?? []), $ref, $i >= 0 ? $lists[$i] : null); + if ($i >= 0) $lists[$i] = $new; else array_unshift($lists, $new); + wr('lists.json', $lists); out(['ok' => true, 'list' => $new]); + +case 'list_delete': + if ($admin) fail('forbidden', 403); + wr('lists.json', array_values(array_filter(load_lists(), fn($x) => $x['id'] !== ($in['id'] ?? '')))); + out(['ok' => true]); + +case 'transfer': + if ($admin) fail('forbidden', 403); + $lists = load_lists(); + $si = list_idx($lists, (string)($in['from'] ?? '')); + if ($si < 0) fail('Исходный список не найден', 404); + $ids = array_map('strval', (array)($in['ids'] ?? [])); + $picked = array_values(array_filter($lists[$si]['items'], fn($it) => in_array($it['id'], $ids, true))); + if (!$picked) fail('Не выбрано ни одного предмета'); + $copy = fn() => array_map(fn($it) => ['id' => new_id(), 'v' => $it['v']], $picked); + $move = !empty($in['move']); + if ($move && !empty($lists[$si]['final'])) fail('Исходный список закрыт итогом — перенос с удалением невозможен'); + $to = (array)($in['to'] ?? []); + if (isset($to['existing'])) { + $ti = list_idx($lists, (string)$to['existing']); + if ($ti < 0 || $ti === $si) fail('Список назначения не найден', 404); + if (!empty($lists[$ti]['final'])) fail('Список назначения закрыт итогом'); + if (!in_array('name', $lists[$si]['cols'], true) || !in_array('name', $lists[$ti]['cols'], true)) fail('В обоих списках должна быть колонка «Название»'); + if (!same_cols($lists[$si], $lists[$ti]) && empty($in['confirm'])) fail('Колонки списков отличаются, нужно подтверждение'); + if (count($lists[$ti]['items']) + count($picked) > 5000) fail('Слишком много предметов в списке'); + $lists[$ti]['items'] = array_merge($lists[$ti]['items'], map_items($picked, $lists[$ti], $ref)); + $target = $lists[$ti]['id']; + } else { + $name = str($to['name'] ?? '', 100); + $lt = (string)($to['ltype'] ?? ''); + if ($name === '') fail('Укажите название списка'); + if (!in_array($lt, ltypes($ref), true)) fail('Выберите тип списка'); + $nl = ['id' => new_id('l'), 'name' => $name, 'kind' => $lists[$si]['kind'], 'ltype' => $lt, + 'cols' => $lists[$si]['cols'], 'final' => false, 'items' => $copy()]; + array_unshift($lists, $nl); $si++; + $target = $nl['id']; + } + if ($move) { + $lists[$si]['items'] = array_values(array_filter($lists[$si]['items'], fn($it) => !in_array($it['id'], $ids, true))); + } + wr('lists.json', $lists); + out(['ok' => true, 'target' => $target, 'lists' => $lists]); + +case 'dict_op': + if (!$admin) fail('forbidden', 403); + $op = (string)($in['op'] ?? ''); $did = (string)($in['id'] ?? ''); + $v = str($in['value'] ?? '', 60); $old = (string)($in['old'] ?? ''); + if ($op === 'add_dict') { + if ($v === '') fail('Введите название справочника'); + foreach ($ref['dicts'] as $d) if (mb_strtolower($d['name']) === mb_strtolower($v)) fail('Справочник с таким названием уже есть'); + if (count($ref['dicts']) >= 50) fail('Слишком много справочников'); + $ref['dicts'][] = ['id' => new_id('d'), 'name' => $v, 'values' => []]; + wr('ref.json', $ref); out(['ref' => $ref]); + } + $di = dict_idx($ref, $did); + if ($di < 0) fail('Справочник не найден', 404); + $vals = $ref['dicts'][$di]['values']; + $core = in_array($did, ['cats', 'locs', 'ltypes'], true); + $usedCols = array_keys(array_filter(cols_map($ref), fn($c) => $c['type'] === 'select' && $c['dict'] === $did)); + $lists = load_lists(); + switch ($op) { + case 'rename_dict': + if ($v === '') fail('Введите название'); + foreach ($ref['dicts'] as $d) if ($d['id'] !== $did && mb_strtolower($d['name']) === mb_strtolower($v)) fail('Справочник с таким названием уже есть'); + $ref['dicts'][$di]['name'] = $v; break; + case 'delete_dict': + if ($core) fail('Встроенный справочник удалить нельзя'); + if ($usedCols) fail('Справочник используется в колонках — сначала удалите или измените их'); + array_splice($ref['dicts'], $di, 1); break; + case 'add_value': + if ($v === '' || in_array($v, $vals, true)) fail('Пустое или повторяющееся значение'); + $ref['dicts'][$di]['values'][] = $v; break; + case 'rename_value': + $i = array_search($old, $vals, true); + if ($i === false || $v === '' || ($v !== $old && in_array($v, $vals, true))) fail('Пустое или повторяющееся значение'); + $ref['dicts'][$di]['values'][$i] = $v; + foreach ($lists as &$l) foreach ($l['items'] as &$it) foreach ($usedCols as $c) if (($it['v'][$c] ?? null) === $old) $it['v'][$c] = $v; + unset($l, $it); + if ($did === 'ltypes') foreach ($lists as &$l) if (($l['ltype'] ?? '') === $old) $l['ltype'] = $v; + unset($l); wr('lists.json', $lists); break; + case 'delete_value': + $i = array_search($old, $vals, true); + if ($i === false) fail('Не найдено', 404); + if ($core && count($vals) < 2) fail('Нельзя удалить последнее значение'); + if ($did === 'ltypes') foreach ($lists as $l) if (($l['ltype'] ?? '') === $old) fail("Тип «{$old}» назначен спискам (например, «{$l['name']}»). Сначала смените им тип."); + foreach ($lists as $l) foreach ($l['items'] as $it) foreach ($usedCols as $c) if (($it['v'][$c] ?? null) === $old) fail("«{$old}» используется в предметах"); + array_splice($ref['dicts'][$di]['values'], (int)$i, 1); break; + default: fail('op'); + } + wr('ref.json', $ref); out(['ref' => $ref]); + +case 'col_op': + if (!$admin) fail('forbidden', 403); + $op = (string)($in['op'] ?? ''); $cid = (string)($in['id'] ?? ''); + $name = str($in['name'] ?? '', 60); $type = (string)($in['type'] ?? ''); $dict = $in['dict'] ?? null; + $lists = load_lists(); + $usedIn = function (string $id) use ($lists) { return array_values(array_map(fn($l) => $l['name'], array_filter($lists, fn($l) => in_array($id, $l['cols'], true)))); }; + $checkName = function () use ($ref, $name, $cid) { + if ($name === '') fail('Введите название колонки'); + foreach (cols_map($ref) as $c) if ($c['id'] !== $cid && mb_strtolower($c['name']) === mb_strtolower($name)) fail('Колонка с таким названием уже есть'); + }; + $checkType = function () use ($ref, $type, &$dict) { + if (!in_array($type, COL_TYPES, true)) fail('Неверный тип колонки'); + if ($type === 'select') { if ((string)$dict === 'ltypes' || dict_idx($ref, (string)$dict) < 0) fail('Выберите справочник для списка'); $dict = (string)$dict; } + else $dict = null; + }; + if ($op === 'add') { + $checkName(); $checkType(); + if (count($ref['columns']) >= 50) fail('Слишком много колонок'); + $ref['columns'][] = ['id' => new_id('c'), 'name' => $name, 'type' => $type, 'dict' => $dict]; + } else { + $ci = -1; foreach ($ref['columns'] as $i => $c) if ($c['id'] === $cid) $ci = $i; + if ($ci < 0) fail('Колонка не найдена', 404); + if ($op === 'update') { + $checkName(); + $c = $ref['columns'][$ci]; + $dict = $dict === '' ? null : $dict; + if ($type !== $c['type'] || ($type === 'select' && (string)$dict !== (string)$c['dict'])) { + if ($u = $usedIn($cid)) fail('Тип колонки нельзя менять: она уже используется в списках (' . implode(', ', array_slice($u, 0, 3)) . ')'); + $checkType(); + $ref['columns'][$ci]['type'] = $type; $ref['columns'][$ci]['dict'] = $dict; + } + $ref['columns'][$ci]['name'] = $name; + } elseif ($op === 'delete') { + if ($u = $usedIn($cid)) fail('Колонка используется в списках: ' . implode(', ', array_slice($u, 0, 3))); + array_splice($ref['columns'], $ci, 1); + } else fail('op'); + } + wr('ref.json', $ref); out(['ref' => $ref]); + +default: fail('unknown'); +} diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png new file mode 100644 index 0000000..9fa5b8e Binary files /dev/null and b/icons/apple-touch-icon.png differ diff --git a/icons/favicon-32.png b/icons/favicon-32.png new file mode 100644 index 0000000..a3925eb Binary files /dev/null and b/icons/favicon-32.png differ diff --git a/icons/favicon.svg b/icons/favicon.svg new file mode 100644 index 0000000..05decc0 --- /dev/null +++ b/icons/favicon.svg @@ -0,0 +1,10 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplMjA2MjUxMy03MmI2LTQ0NTUtODY5ZC0xNjBlOTIzNTQ2YTgAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaJdrAJ9pjMJgmDwhoEK922sAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDo3YTY2ODQ4My03ZTdiLTQ4ZDQtYmIxYy03YmNiMmZmZjQyYWZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNo9AqJugjLK9Tv/YAI1ABNiwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggCHVhv9Wa+acWK2oozzvNikmNwIltospBqVgUpTLKYSqkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaEfup5O1mG+ZNM6zR6v68J4AAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCAq9XrR10PKA1R/sEMn0TuNePa93vZiF5gt00g7qOhVzmRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiiZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmUyMDYyNTEzLTcyYjYtNDQ1NS04NjlkLTE2MGU5MjM1NDZhOC9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmY3ZDdiZWRjLTdjMmQtNGYzNy04MmEyLTBjY2VhOGQ5MzMxZHJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAIdWG/1Zr5pxYraijPO82KSY3AiW2iykGpWBSlMsphKqJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFgg5kL1Q39WQLzUai1hYfRLmWUxowBTRxqDhxvbY9ISIAaiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg6Qfv+m1sS8HxvFyILudyNEAEc8MDNPI9sgYGcc4kdI10Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQIdmEe5b8rZbxQ1LPQEfhfOCO+Px9LYOVg09a9dHkJk561Gm9KQrXaR5p0joKg/Y/XEsOlYKe+2y0ysxBLajkTg= + + + + + + + + + diff --git a/icons/icon-192.png b/icons/icon-192.png new file mode 100644 index 0000000..0fff29d Binary files /dev/null and b/icons/icon-192.png differ diff --git a/icons/icon-512.png b/icons/icon-512.png new file mode 100644 index 0000000..e21d330 Binary files /dev/null and b/icons/icon-512.png differ diff --git a/icons/maskable-192.png b/icons/maskable-192.png new file mode 100644 index 0000000..0ac34a9 Binary files /dev/null and b/icons/maskable-192.png differ diff --git a/icons/maskable-512.png b/icons/maskable-512.png new file mode 100644 index 0000000..a06a881 Binary files /dev/null and b/icons/maskable-512.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..20e5114 --- /dev/null +++ b/index.html @@ -0,0 +1,641 @@ + + + +inventorEZ + + + + + + + + +
inventorEZ
+ +
+
+
+
+ + + diff --git a/manifest.webmanifest b/manifest.webmanifest new file mode 100644 index 0000000..752d7a4 --- /dev/null +++ b/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "inventorEZ", + "short_name": "inventorEZ", + "start_url": "./", + "display": "standalone", + "background_color": "#f6efdf", + "theme_color": "#f6efdf", + "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/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable"}, + {"src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"} + ] +}