coderuday21 Cursor commited on
Commit
8354653
·
1 Parent(s): 333dd2f

HF library: auto DDA on satdetect-dev, upload to persistent storage

Browse files
Dockerfile CHANGED
@@ -21,7 +21,7 @@ WORKDIR /app
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
- ARG APP_BUILD=28
25
  ENV APP_BUILD=${APP_BUILD}
26
  ENV GDAL_CONFIG=/usr/bin/gdal-config
27
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
 
21
 
22
  # Build-time info + cache-bust:
23
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
24
+ ARG APP_BUILD=29
25
  ENV APP_BUILD=${APP_BUILD}
26
  ENV GDAL_CONFIG=/usr/bin/gdal-config
27
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
app/dda/bootstrap.py CHANGED
@@ -4,7 +4,7 @@ from fastapi import FastAPI
4
  from sqlalchemy import text as sa_text
5
 
6
  from ..database import engine
7
- from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders
8
  from .library_routes import router as library_router
9
  from .local_routes import router as local_router
10
  from .seed import seed_delhi_hierarchy
@@ -35,6 +35,18 @@ def init_dda_database():
35
  finally:
36
  db.close()
37
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  def setup_dda(app: FastAPI) -> None:
40
  if not IS_DDA_MODE:
 
4
  from sqlalchemy import text as sa_text
5
 
6
  from ..database import engine
7
+ from .config import IS_DDA_MODE, ensure_library_dirs, ensure_local_year_folders, is_hf_hosted
8
  from .library_routes import router as library_router
9
  from .local_routes import router as local_router
10
  from .seed import seed_delhi_hierarchy
 
35
  finally:
36
  db.close()
37
 
38
+ try:
39
+ from .local_library import library_debug_info, scan_images
40
+ info = library_debug_info()
41
+ logger.info(
42
+ "DDA library ready (hosted=%s): %d images, writable=%s",
43
+ is_hf_hosted(),
44
+ len(scan_images()),
45
+ info.get("roots", [{}])[0].get("path") if info.get("roots") else "?",
46
+ )
47
+ except Exception as exc:
48
+ logger.warning("Library scan at startup failed: %s", exc)
49
+
50
 
51
  def setup_dda(app: FastAPI) -> None:
52
  if not IS_DDA_MODE:
app/dda/config.py CHANGED
@@ -4,19 +4,46 @@ from typing import List
4
 
5
  from ..database import DATA_DIR
6
 
7
- APP_MODE = os.environ.get("APP_MODE", "legacy").strip().lower()
8
- IS_DDA_MODE = APP_MODE == "dda"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  # Project root: change_detection_webapp/
11
  PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
12
 
13
 
 
 
 
 
 
14
  def get_library_roots() -> List[Path]:
15
- """All folders scanned for year-based images (project + writable data copy)."""
16
  roots: List[Path] = []
17
  if os.environ.get("LOCAL_LIBRARY_ROOT"):
18
  roots.append(Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve())
19
- for candidate in (PROJECT_ROOT / "library_sources", DATA_DIR / "library_sources"):
 
 
 
 
 
20
  resolved = candidate.resolve()
21
  if resolved not in roots:
22
  roots.append(resolved)
 
4
 
5
  from ..database import DATA_DIR
6
 
7
+ APP_MODE_RAW = os.environ.get("APP_MODE", "").strip().lower()
8
+ _SPACE_ID = os.environ.get("SPACE_ID", "").strip().lower()
9
+
10
+
11
+ def is_hf_hosted() -> bool:
12
+ return bool(_SPACE_ID)
13
+
14
+
15
+ def _is_dda_mode() -> bool:
16
+ if APP_MODE_RAW == "legacy":
17
+ return False
18
+ if APP_MODE_RAW == "dda":
19
+ return True
20
+ # APP_MODE unset: auto-enable on satdetect-dev only (production satdetect stays legacy)
21
+ return _SPACE_ID.endswith("/satdetect-dev")
22
+
23
+
24
+ IS_DDA_MODE = _is_dda_mode()
25
+ APP_MODE = APP_MODE_RAW or ("dda" if IS_DDA_MODE else "legacy")
26
 
27
  # Project root: change_detection_webapp/
28
  PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
29
 
30
 
31
+ def get_writable_library_root() -> Path:
32
+ """Persistent folder for uploads (HF: /home/appuser/data/library_sources)."""
33
+ return (DATA_DIR / "library_sources").resolve()
34
+
35
+
36
  def get_library_roots() -> List[Path]:
37
+ """Folders scanned for year-based images."""
38
  roots: List[Path] = []
39
  if os.environ.get("LOCAL_LIBRARY_ROOT"):
40
  roots.append(Path(os.environ["LOCAL_LIBRARY_ROOT"]).resolve())
41
+ # Writable data dir first on Hugging Face (where uploads land)
42
+ if is_hf_hosted():
43
+ wr = get_writable_library_root()
44
+ if wr not in roots:
45
+ roots.append(wr)
46
+ for candidate in (get_writable_library_root(), PROJECT_ROOT / "library_sources"):
47
  resolved = candidate.resolve()
48
  if resolved not in roots:
49
  roots.append(resolved)
app/dda/local_routes.py CHANGED
@@ -1,10 +1,19 @@
1
  """API for reading images from local library_sources/ year folders."""
 
 
2
  from typing import Optional
3
 
4
- from fastapi import APIRouter, HTTPException, Query
5
  from fastapi.responses import FileResponse
6
 
7
- from .config import IS_DDA_MODE, geotiff_io_available, get_library_roots
 
 
 
 
 
 
 
8
  from .local_library import (
9
  entry_to_dict,
10
  get_or_build_thumb,
@@ -13,28 +22,52 @@ from .local_library import (
13
  scan_images,
14
  scan_years,
15
  )
 
16
 
 
17
  router = APIRouter()
18
 
19
 
20
  def _require_dda():
21
  if not IS_DDA_MODE:
22
- raise HTTPException(status_code=404, detail="DDA mode is not enabled on this server")
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  @router.get("/local/config")
26
  def local_library_config():
27
  _require_dda()
28
  roots = [str(r) for r in get_library_roots()]
 
 
 
 
 
 
 
 
 
 
 
 
29
  return {
30
  "source": "local_folder",
 
 
 
31
  "rootPath": roots[0] if roots else "",
32
  "rootPaths": roots,
33
- "instructions": (
34
- "Copy .tif / .tiff images into library_sources/YEAR/ inside the project folder "
35
- "(e.g. change_detection_webapp/library_sources/2025/), then click Refresh. "
36
- "Run locally with: python run.py (DDA mode is enabled automatically)."
37
- ),
38
  "geotiffEnabled": geotiff_io_available(),
39
  }
40
 
@@ -65,11 +98,11 @@ def local_images(
65
  def local_image_detail(path: str = Query(..., description="Relative path e.g. 2025/aerial.tif")):
66
  _require_dda()
67
  entries = scan_images()
68
- match = next((e for e in entries if e.path == path.replace("\\", "/")), None)
 
69
  if not match:
70
- safe_resolve(path) # raises 404 if missing
71
- entries = scan_images()
72
- match = next((e for e in entries if e.path == path.replace("\\", "/")), None)
73
  if not match:
74
  raise HTTPException(status_code=404, detail="Image not found in library scan")
75
  return entry_to_dict(match, include_meta=True)
@@ -87,9 +120,60 @@ def local_thumb(path: str = Query(...)):
87
  raise HTTPException(status_code=500, detail=f"Thumbnail failed: {exc}") from exc
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  @router.post("/local/rescan")
91
  def local_rescan():
92
  _require_dda()
93
  years = scan_years()
94
  total = sum(y["imageCount"] for y in years)
95
- return {"ok": True, "years": years, "totalImages": total, "rootPaths": [str(r) for r in get_library_roots()]}
 
 
 
 
 
 
 
 
 
 
1
  """API for reading images from local library_sources/ year folders."""
2
+ import logging
3
+ from pathlib import Path
4
  from typing import Optional
5
 
6
+ from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
7
  from fastapi.responses import FileResponse
8
 
9
+ from .config import (
10
+ IS_DDA_MODE,
11
+ geotiff_io_available,
12
+ get_library_roots,
13
+ get_writable_library_root,
14
+ is_hf_hosted,
15
+ max_upload_bytes_for_extension,
16
+ )
17
  from .local_library import (
18
  entry_to_dict,
19
  get_or_build_thumb,
 
22
  scan_images,
23
  scan_years,
24
  )
25
+ from .upload_io import stream_upload_to_file
26
 
27
+ logger = logging.getLogger(__name__)
28
  router = APIRouter()
29
 
30
 
31
  def _require_dda():
32
  if not IS_DDA_MODE:
33
+ raise HTTPException(
34
+ status_code=404,
35
+ detail="DDA mode is not enabled. On HF dev Space this is automatic; locally use: python run.py",
36
+ )
37
+
38
+
39
+ def _safe_basename(filename: str) -> str:
40
+ name = Path(filename or "upload").name
41
+ if not name or name in (".", ".."):
42
+ raise HTTPException(status_code=400, detail="Invalid filename")
43
+ return name
44
 
45
 
46
  @router.get("/local/config")
47
  def local_library_config():
48
  _require_dda()
49
  roots = [str(r) for r in get_library_roots()]
50
+ writable = str(get_writable_library_root())
51
+ hosted = is_hf_hosted()
52
+ if hosted:
53
+ instructions = (
54
+ "On Hugging Face, images must be uploaded below (saved to persistent storage) "
55
+ "or copied into the writable folder shown. Files on your PC are not visible here."
56
+ )
57
+ else:
58
+ instructions = (
59
+ "Copy .tif images into library_sources/YEAR/ in your project folder, then Refresh. "
60
+ "Or use Upload to save into data/library_sources/."
61
+ )
62
  return {
63
  "source": "local_folder",
64
+ "isHosted": hosted,
65
+ "spaceId": __import__("os").environ.get("SPACE_ID", ""),
66
+ "appMode": "dda" if IS_DDA_MODE else "legacy",
67
  "rootPath": roots[0] if roots else "",
68
  "rootPaths": roots,
69
+ "writablePath": writable,
70
+ "instructions": instructions,
 
 
 
71
  "geotiffEnabled": geotiff_io_available(),
72
  }
73
 
 
98
  def local_image_detail(path: str = Query(..., description="Relative path e.g. 2025/aerial.tif")):
99
  _require_dda()
100
  entries = scan_images()
101
+ norm = path.replace("\\", "/")
102
+ match = next((e for e in entries if e.path == norm), None)
103
  if not match:
104
+ safe_resolve(path)
105
+ match = next((e for e in scan_images() if e.path == norm), None)
 
106
  if not match:
107
  raise HTTPException(status_code=404, detail="Image not found in library scan")
108
  return entry_to_dict(match, include_meta=True)
 
120
  raise HTTPException(status_code=500, detail=f"Thumbnail failed: {exc}") from exc
121
 
122
 
123
+ @router.post("/local/upload")
124
+ async def local_upload(
125
+ file: UploadFile = File(...),
126
+ year: int = Form(...),
127
+ ):
128
+ """Upload GeoTIFF into persistent library_sources/YEAR/ (required on HF)."""
129
+ _require_dda()
130
+ if year < 1990 or year > 2100:
131
+ raise HTTPException(status_code=400, detail="year must be between 1990 and 2100")
132
+
133
+ original = _safe_basename(file.filename or "upload")
134
+ ext = Path(original).suffix.lower()
135
+ from .config import ALLOWED_EXTENSIONS
136
+ if ext not in ALLOWED_EXTENSIONS:
137
+ raise HTTPException(status_code=400, detail=f"Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}")
138
+
139
+ root = get_writable_library_root()
140
+ dest = root / str(year) / original
141
+ if dest.exists():
142
+ stem = Path(original).stem
143
+ suffix = Path(original).suffix
144
+ n = 1
145
+ while dest.exists():
146
+ dest = root / str(year) / f"{stem}_{n}{suffix}"
147
+ n += 1
148
+
149
+ size = await stream_upload_to_file(file, dest, max_upload_bytes_for_extension(ext))
150
+ rel = dest.relative_to(root).as_posix()
151
+ logger.info("Library upload: %s (%d bytes) -> %s", original, size, dest)
152
+
153
+ entries = scan_images(year=year)
154
+ match = next((e for e in entries if e.path == rel or e.filename == dest.name), None)
155
+ if match:
156
+ return {"status": "success", "path": match.path, "image": entry_to_dict(match)}
157
+ return {
158
+ "status": "success",
159
+ "path": f"{year}/{dest.name}",
160
+ "fileSizeBytes": size,
161
+ "writablePath": str(dest),
162
+ }
163
+
164
+
165
  @router.post("/local/rescan")
166
  def local_rescan():
167
  _require_dda()
168
  years = scan_years()
169
  total = sum(y["imageCount"] for y in years)
170
+ info = library_debug_info()
171
+ logger.info("Library rescan: %d images, roots=%s", total, info.get("roots"))
172
+ return {
173
+ "ok": True,
174
+ "years": years,
175
+ "totalImages": total,
176
+ "rootPaths": [str(r) for r in get_library_roots()],
177
+ "writablePath": str(get_writable_library_root()),
178
+ "debug": info,
179
+ }
app/main.py CHANGED
@@ -87,6 +87,7 @@ def health():
87
  "status": "ok",
88
  "version": "2.3.0-dda" if IS_DDA_MODE else "2.2.0",
89
  "appMode": "dda" if IS_DDA_MODE else "legacy",
 
90
  "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
91
  }
92
 
 
87
  "status": "ok",
88
  "version": "2.3.0-dda" if IS_DDA_MODE else "2.2.0",
89
  "appMode": "dda" if IS_DDA_MODE else "legacy",
90
+ "spaceId": os.environ.get("SPACE_ID", ""),
91
  "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
92
  }
93
 
static/js/dda/app.js CHANGED
@@ -42,6 +42,7 @@ let selectedYear = null;
42
 
43
  window.ddaState = {
44
  get config() { return ddaConfig; },
 
45
  get years() { return localYears; },
46
  get selectedYear() { return selectedYear; },
47
  setYear(year) { selectedYear = year; },
@@ -72,16 +73,36 @@ async function initDda() {
72
  try {
73
  ddaConfig = await ddaApi('GET', '/api/dda/config');
74
  const localCfg = await ddaApi('GET', '/api/dda/local/config');
 
75
 
76
  const hint = document.getElementById('lib-config-hint');
77
  if (hint) {
78
  hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
79
  }
80
- const paths = (localCfg.rootPaths || [localCfg.rootPath || ddaConfig.localLibraryPaths?.[0]]).filter(Boolean);
81
  const pathEl = document.getElementById('lib-path-display');
82
- if (pathEl) pathEl.textContent = paths.join('\n');
 
 
 
 
 
 
83
  const folderPath = document.getElementById('lib-folder-path');
84
- if (folderPath) folderPath.textContent = paths.length ? `Scanning: ${paths[0]}` : '';
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  const yearsData = await ddaApi('GET', '/api/dda/local/years');
87
  localYears = yearsData.years || [];
@@ -106,7 +127,10 @@ async function loadLibraryImages() {
106
  try {
107
  const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
108
  if (!items.length) {
109
- grid.innerHTML = `<p class="dim">No images in ${selectedYear || 'library_sources'}. Copy .tif files into <code>library_sources/${selectedYear || 'YEAR'}/</code> and click Refresh.</p>`;
 
 
 
110
  return;
111
  }
112
  grid.innerHTML = items.map((img) => {
 
42
 
43
  window.ddaState = {
44
  get config() { return ddaConfig; },
45
+ get localCfg() { return window._localCfg; },
46
  get years() { return localYears; },
47
  get selectedYear() { return selectedYear; },
48
  setYear(year) { selectedYear = year; },
 
73
  try {
74
  ddaConfig = await ddaApi('GET', '/api/dda/config');
75
  const localCfg = await ddaApi('GET', '/api/dda/local/config');
76
+ window._localCfg = localCfg;
77
 
78
  const hint = document.getElementById('lib-config-hint');
79
  if (hint) {
80
  hint.textContent = localCfg.geotiffEnabled ? 'GeoTIFF ready' : 'GeoTIFF limited';
81
  }
82
+ const paths = (localCfg.rootPaths || []).filter(Boolean);
83
  const pathEl = document.getElementById('lib-path-display');
84
+ if (pathEl) {
85
+ pathEl.textContent = [
86
+ localCfg.isHosted ? 'HF writable storage:' : 'Local folders:',
87
+ localCfg.writablePath || paths[0] || '',
88
+ ...paths.filter((p) => p !== localCfg.writablePath),
89
+ ].filter(Boolean).join('\n');
90
+ }
91
  const folderPath = document.getElementById('lib-folder-path');
92
+ if (folderPath) {
93
+ folderPath.textContent = localCfg.isHosted
94
+ ? 'Hugging Face — upload files below'
95
+ : (paths[0] ? `Scanning: ${paths[0]}` : '');
96
+ }
97
+ const instr = document.getElementById('lib-instructions');
98
+ if (instr && localCfg.instructions) instr.textContent = localCfg.instructions;
99
+
100
+ const hfUpload = document.getElementById('hf-upload-card');
101
+ if (hfUpload) hfUpload.classList.toggle('hidden', !localCfg.isHosted);
102
+
103
+ if (!localCfg.isHosted && ddaConfig.appMode !== 'dda') {
104
+ showDdaError('DDA mode is off. Run locally with: python run.py');
105
+ }
106
 
107
  const yearsData = await ddaApi('GET', '/api/dda/local/years');
108
  localYears = yearsData.years || [];
 
127
  try {
128
  const items = await ddaApi('GET', '/api/dda/local/images?' + params.toString());
129
  if (!items.length) {
130
+ const hf = window.ddaState?.localCfg?.isHosted;
131
+ grid.innerHTML = hf
132
+ ? `<p class="dim">No images on this Space yet. Use <strong>Upload to Space storage</strong> above (2025 / 2026), then click Refresh.</p>`
133
+ : `<p class="dim">No images in ${selectedYear || 'library_sources'}. Copy .tif files into <code>library_sources/${selectedYear || 'YEAR'}/</code> and click Refresh.</p>`;
134
  return;
135
  }
136
  grid.innerHTML = items.map((img) => {
static/js/dda/library.js CHANGED
@@ -31,3 +31,68 @@ function renderYearTree(years) {
31
  document.getElementById('lib-tree-search')?.addEventListener('input', () => {
32
  if (window.ddaState?.years) renderYearTree(window.ddaState.years);
33
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  document.getElementById('lib-tree-search')?.addEventListener('input', () => {
32
  if (window.ddaState?.years) renderYearTree(window.ddaState.years);
33
  });
34
+
35
+ function uploadWithProgress(url, formData, onProgress) {
36
+ return new Promise((resolve, reject) => {
37
+ const xhr = new XMLHttpRequest();
38
+ xhr.open('POST', url);
39
+ xhr.withCredentials = true;
40
+ xhr.upload.addEventListener('progress', (e) => {
41
+ if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total);
42
+ });
43
+ xhr.addEventListener('load', () => {
44
+ let data = null;
45
+ try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch (_) {}
46
+ if (xhr.status >= 200 && xhr.status < 300) resolve(data);
47
+ else reject(new Error(data?.detail || xhr.statusText || 'Upload failed'));
48
+ });
49
+ xhr.addEventListener('error', () => reject(new Error('Network error during upload')));
50
+ xhr.send(formData);
51
+ });
52
+ }
53
+
54
+ function formatBytes(n) {
55
+ if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
56
+ if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
57
+ return (n / 1024).toFixed(0) + ' KB';
58
+ }
59
+
60
+ document.getElementById('form-hf-upload')?.addEventListener('submit', async (e) => {
61
+ e.preventDefault();
62
+ hideDdaError?.();
63
+ const fileInput = document.getElementById('hf-file');
64
+ const file = fileInput?.files?.[0];
65
+ if (!file) {
66
+ showDdaError?.('Select a .tif file.');
67
+ return;
68
+ }
69
+
70
+ const form = new FormData();
71
+ form.append('file', file);
72
+ form.append('year', document.getElementById('hf-year').value);
73
+
74
+ const btn = document.getElementById('btn-hf-upload');
75
+ const progWrap = document.getElementById('hf-upload-progress');
76
+ const progFill = document.getElementById('hf-upload-progress-fill');
77
+ const progLabel = document.getElementById('hf-upload-progress-label');
78
+
79
+ btn.disabled = true;
80
+ progWrap?.classList.remove('hidden');
81
+ if (progFill) progFill.style.width = '0%';
82
+
83
+ try {
84
+ await uploadWithProgress('/api/dda/local/upload', form, (loaded, total) => {
85
+ const pct = total ? Math.round((loaded / total) * 100) : 0;
86
+ if (progFill) progFill.style.width = pct + '%';
87
+ if (progLabel) progLabel.textContent = `Uploading… ${pct}% (${formatBytes(loaded)} / ${formatBytes(total)})`;
88
+ });
89
+ showDdaSuccess?.('Uploaded to Space library. Click Refresh if images do not appear.');
90
+ fileInput.value = '';
91
+ await window.ddaState.rescan();
92
+ } catch (err) {
93
+ showDdaError?.(err.message || 'Upload failed. Large files may exceed HF timeout — try a smaller file or run locally.');
94
+ } finally {
95
+ btn.disabled = false;
96
+ setTimeout(() => progWrap?.classList.add('hidden'), 2000);
97
+ }
98
+ });
templates/index_dda.html CHANGED
@@ -48,6 +48,30 @@
48
  </p>
49
  <pre id="lib-path-display" class="dda-path-code"></pre>
50
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  <div class="card">
52
  <div class="card-header">
53
  <h3 id="lib-grid-title">Images</h3>
@@ -88,7 +112,7 @@
88
  </section>
89
  </div>
90
 
91
- <script src="/static/js/dda/app.js?v=4"></script>
92
- <script src="/static/js/dda/library.js?v=4"></script>
93
  </body>
94
  </html>
 
48
  </p>
49
  <pre id="lib-path-display" class="dda-path-code"></pre>
50
  </div>
51
+ <div class="card hidden" id="hf-upload-card">
52
+ <div class="card-header">
53
+ <h3>Upload to Space storage</h3>
54
+ <span class="dim">Required on Hugging Face</span>
55
+ </div>
56
+ <p class="sub">Files on your computer are not on the server. Upload .tif images here (up to 2 GB each).</p>
57
+ <form id="form-hf-upload" class="dda-upload-form">
58
+ <div class="location-row">
59
+ <div class="form-group">
60
+ <label for="hf-year">Year folder</label>
61
+ <input type="number" id="hf-year" required min="2000" max="2100" value="2025" />
62
+ </div>
63
+ <div class="form-group">
64
+ <label for="hf-file">GeoTIFF file</label>
65
+ <input type="file" id="hf-file" accept=".tif,.tiff" required />
66
+ </div>
67
+ </div>
68
+ <div id="hf-upload-progress" class="dda-upload-progress hidden">
69
+ <div class="dda-progress-bar"><div id="hf-upload-progress-fill" class="dda-progress-fill"></div></div>
70
+ <span id="hf-upload-progress-label" class="dim">Uploading…</span>
71
+ </div>
72
+ <button type="submit" class="btn btn-primary" id="btn-hf-upload">Upload to library</button>
73
+ </form>
74
+ </div>
75
  <div class="card">
76
  <div class="card-header">
77
  <h3 id="lib-grid-title">Images</h3>
 
112
  </section>
113
  </div>
114
 
115
+ <script src="/static/js/dda/app.js?v=5"></script>
116
+ <script src="/static/js/dda/library.js?v=5"></script>
117
  </body>
118
  </html>