From 4e0d2abbd0506af7894061b03fa9061bff0da44c Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Tue, 25 Aug 2026 14:05:39 +0200 Subject: [PATCH] Add GPX upload with hierarchical folders and map display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload GPX files into a folder hierarchy (traversal-safe path resolution against the store root), browse them in a tree panel, and toggle each one's waypoints/tracks on the map. Waypoints render as clickable points showing name, description and time; bare URLs in the description are turned into safe links. GPX layers survive switching between the Online/Offline base map, same as the radar overlay. Adds rexml as an explicit dependency — it moved to a bundled gem in this Ruby version and isn't picked up by Bundler otherwise. --- .gitignore | 4 + Gemfile | 1 + Gemfile.lock | 3 + README.adoc | 3 +- app.rb | 35 ++++++ data/gpx/.gitkeep | 0 docs/architecture.adoc | 52 ++++++++- lib/maptool/gpx.rb | 67 +++++++++++ lib/maptool/gpx_store.rb | 87 ++++++++++++++ public/css/style.css | 117 +++++++++++++++++++ public/js/gpx.js | 244 +++++++++++++++++++++++++++++++++++++++ public/js/map.js | 2 + views/index.erb | 16 +++ 13 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 data/gpx/.gitkeep create mode 100644 lib/maptool/gpx.rb create mode 100644 lib/maptool/gpx_store.rb create mode 100644 public/js/gpx.js diff --git a/.gitignore b/.gitignore index 6afbe0f..fbf419f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ # see docs/development.adoc) /data/*.pmtiles +# User-uploaded GPX files +/data/gpx/* +!/data/gpx/.gitkeep + # Editor / OS .DS_Store *.swp diff --git a/Gemfile b/Gemfile index b706871..a8da8f9 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" gem "sinatra", "~> 4.2" gem "sinatra-contrib", "~> 4.2" gem "puma", "~> 8.0" +gem "rexml", "~> 3.4" group :development do gem "rerun" diff --git a/Gemfile.lock b/Gemfile.lock index e0e9a77..ea12060 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,6 +35,7 @@ GEM ffi (~> 1.0) rerun (0.14.0) listen (~> 3.0) + rexml (3.4.4) sinatra (4.2.1) logger (>= 1.6.0) mustermann (~> 3.0) @@ -65,6 +66,7 @@ PLATFORMS DEPENDENCIES puma (~> 8.0) rerun + rexml (~> 3.4) sinatra (~> 4.2) sinatra-contrib (~> 4.2) @@ -93,6 +95,7 @@ CHECKSUMS rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rerun (0.14.0) sha256=996a5af2b2b34dcb30146c6c728addab9113e07c119003370daa9bf27feb88fd + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 sinatra-contrib (4.2.1) sha256=10d091c944d268aa910c618ea40a3c3ebe0533e6e32990d84af92235a3d26b4a tilt (2.9.0) sha256=da5735d0280bba96e9a91041bb14aee435ccad5c17b0fa519249ae543d9aa3a5 diff --git a/README.adoc b/README.adoc index f1ad4b5..7794b24 100644 --- a/README.adoc +++ b/README.adoc @@ -11,7 +11,8 @@ The project's shape is still open — this is the initial scaffold. Early scaffold. The web application serves a vector map (MapLibre GL JS), with an online style and an optional offline style backed by a -local PMTiles file, plus an animated RainViewer radar overlay. +local PMTiles file, an animated RainViewer radar overlay, and GPX +upload/display (points and tracks, organized in folders). == Requirements diff --git a/app.rb b/app.rb index 7d7c7e9..f8a8a3b 100644 --- a/app.rb +++ b/app.rb @@ -1,5 +1,7 @@ require "sinatra/base" require "sinatra/json" +require_relative "lib/maptool/gpx" +require_relative "lib/maptool/gpx_store" module Maptool class App < Sinatra::Base @@ -7,6 +9,7 @@ module Maptool set :views, File.join(__dir__, "views") OFFLINE_PMTILES_PATH = File.join(__dir__, "data", "offline.pmtiles") + GPX_STORE = GpxStore.new(File.join(__dir__, "data", "gpx")) get "/" do erb :index @@ -28,5 +31,37 @@ module Maptool send_file OFFLINE_PMTILES_PATH, type: "application/octet-stream" end + + get "/api/gpx/tree" do + json GPX_STORE.tree + end + + post "/api/gpx" do + file = params[:file] + halt 400, json(error: "file is required") unless file && file[:tempfile] + + begin + path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile]) + rescue GpxStore::InvalidPath + halt 400, json(error: "invalid folder") + end + + json path: path, tree: GPX_STORE.tree + end + + get "/api/gpx/file/*" do + begin + path = GPX_STORE.resolve_file(params[:splat].first) + rescue GpxStore::InvalidPath + halt 400 + end + halt 404 unless File.file?(path) + + begin + json Gpx.to_geojson(path) + rescue Gpx::ParseError + halt 422, json(error: "could not parse GPX file") + end + end end end diff --git a/data/gpx/.gitkeep b/data/gpx/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/architecture.adoc b/docs/architecture.adoc index f810b54..0724b51 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -30,7 +30,11 @@ opened in a browser. `docs/third-party-notices.adoc`. | `data/` -| Local, gitignored data files, e.g. `offline.pmtiles`. +| Local, gitignored data files, e.g. `offline.pmtiles` and uploaded + GPX files under `data/gpx/`. + +| `lib/maptool/` +| Application code beyond routing: the GPX parser and file store. | `docs/` | Project documentation, in AsciiDoc. @@ -110,6 +114,52 @@ This is a live, online-only overlay — it always needs internet access and is unrelated to the offline PMTiles basemap above. Per RainViewer's terms, the API is free for personal/non-commercial use. +== GPX tracks and waypoints + +The "GPX" map control (`public/js/gpx.js`) opens a side panel to upload +GPX files and toggle them on the map, individually. + +Storage:: Uploaded files are stored as-is under `data/gpx/`, in +whatever folder hierarchy the user types into the upload form (e.g. +`Hiking/2026`) — `Maptool::GpxStore` +(`lib/maptool/gpx_store.rb`) creates folders as needed. Every path +coming from the client (upload folder, file path in a URL) is +resolved with `File.expand_path` and checked against the store root +before touching the filesystem, so `../..`-style path traversal can't +escape `data/gpx/`. +`GET /api/gpx/tree` returns the resulting folder/file tree as JSON, +which the panel renders recursively (folders as `
`, files as +a checkbox). + +Rendering:: Checking a file fetches `GET +/api/gpx/file/` — `Maptool::Gpx` (`lib/maptool/gpx.rb`) parses +the GPX with REXML and converts it to a GeoJSON `FeatureCollection`: +`` waypoints become `Point` features (with `name`, `desc` and +`time` carried through as properties), each `` becomes one +`MultiLineString` feature (one line per ``). MapLibre's +`geometry-type` expression treats `MultiLineString` the same as +`LineString`, so a single GeoJSON source per file with a `line` layer +and a `circle` layer (each filtered by geometry type) is enough to +render both waypoints and tracks. `` routes are not handled. + +Like the radar overlay, GPX layers are removed by `map.setStyle()` +(switching Online/Offline) and get re-added on `style.load` for +whichever files were checked. + +Waypoint popups:: Clicking a waypoint circle opens a MapLibre popup +with its name, description and time. Click/hover handling is +registered once per file (via `map.on(type, layerId, handler)`, a +delegated listener that survives `setStyle()`) rather than on every +`style.load` re-add, which would otherwise stack duplicate handlers +and pop up several copies per click. Waypoint text comes straight from +an uploaded file — untrusted input — so `public/js/gpx.js`'s +`linkify()` turns bare URLs in the description into `` links by +splitting the text on a URL pattern and escaping every piece +(including the URL itself, as both `href` and link text) before +assembling any HTML; the URL pattern excludes quote characters, so a +matched URL can never break out of the `href="..."` attribute. Every +other value is HTML-escaped before being handed to `Popup#setHTML`. + == Networking The Sinatra server only binds to `127.0.0.1`. It is not meant to be diff --git a/lib/maptool/gpx.rb b/lib/maptool/gpx.rb new file mode 100644 index 0000000..b967c01 --- /dev/null +++ b/lib/maptool/gpx.rb @@ -0,0 +1,67 @@ +require "rexml/document" + +module Maptool + # Converts a GPX file to a GeoJSON FeatureCollection: waypoints () + # become Point features, tracks () become MultiLineString features + # (one line per ). Routes () are not handled — GPX in + # practice is either points or recorded tracks for this app. + module Gpx + class ParseError < StandardError; end + + def self.to_geojson(path) + root = REXML::Document.new(File.read(path)).root + raise ParseError, "not a GPX file" unless root && root.name == "gpx" + + features = [] + root.each_element("wpt") { |wpt| features << waypoint_feature(wpt) } + root.each_element("trk") { |trk| features << track_feature(trk) } + + { "type" => "FeatureCollection", "features" => features } + rescue REXML::ParseException => e + raise ParseError, e.message + end + + def self.waypoint_feature(wpt) + { + "type" => "Feature", + "geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) }, + "properties" => { + "kind" => "waypoint", + "name" => child_text(wpt, "name"), + "description" => child_text(wpt, "desc"), + "time" => child_text(wpt, "time"), + }, + } + end + private_class_method :waypoint_feature + + def self.track_feature(trk) + lines = [] + trk.each_element("trkseg") do |seg| + points = [] + seg.each_element("trkpt") { |pt| points << coordinate(pt) } + lines << points unless points.empty? + end + + { + "type" => "Feature", + "geometry" => { "type" => "MultiLineString", "coordinates" => lines }, + "properties" => { "kind" => "track", "name" => child_text(trk, "name") }, + } + end + private_class_method :track_feature + + def self.coordinate(point_el) + lat = point_el.attributes["lat"].to_f + lon = point_el.attributes["lon"].to_f + ele = child_text(point_el, "ele") + ele ? [lon, lat, ele.to_f] : [lon, lat] + end + private_class_method :coordinate + + def self.child_text(el, name) + el.elements[name]&.text&.strip + end + private_class_method :child_text + end +end diff --git a/lib/maptool/gpx_store.rb b/lib/maptool/gpx_store.rb new file mode 100644 index 0000000..e731e31 --- /dev/null +++ b/lib/maptool/gpx_store.rb @@ -0,0 +1,87 @@ +require "fileutils" + +module Maptool + # Filesystem-backed store for uploaded GPX files, organized in + # user-chosen folders (arbitrary nesting). All paths coming from the + # outside (folder names, file paths in URLs) are resolved and checked + # against the store root before touching the filesystem, so path + # traversal (e.g. "../../etc") can't escape it. + class GpxStore + class InvalidPath < StandardError; end + + def initialize(root) + @root = File.expand_path(root) + FileUtils.mkdir_p(@root) + end + + # Nested { type:, name:, path:, children: } hash describing every + # folder and .gpx file under the store root. + def tree + { "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") } + end + + # Saves `io`'s contents as a .gpx file under `folder` (created if + # needed), deriving a safe filename from `filename`. Returns the + # stored file's path relative to the store root. + def save(folder:, filename:, io:) + dir = resolve_dir(folder.to_s) + FileUtils.mkdir_p(dir) + name = unique_filename(dir, safe_filename(filename)) + File.binwrite(File.join(dir, name), io.read) + relative = folder.to_s.empty? ? name : "#{folder}/#{name}" + relative + end + + # Absolute filesystem path for a store-relative file path, after + # confirming it stays within the store root. + def resolve_file(relative_path) + path = File.expand_path(File.join(@root, relative_path.to_s)) + raise InvalidPath unless within_root?(path) + + path + end + + private + + def resolve_dir(folder) + path = File.expand_path(File.join(@root, folder)) + raise InvalidPath unless within_root?(path) + + path + end + + def within_root?(path) + path == @root || path.start_with?(@root + File::SEPARATOR) + end + + def safe_filename(filename) + base = File.basename(filename.to_s) + base = "upload.gpx" if base.empty? || base == "." || base == ".." + base = "#{base}.gpx" unless base.downcase.end_with?(".gpx") + base + end + + def unique_filename(dir, name) + return name unless File.exist?(File.join(dir, name)) + + ext = File.extname(name) + stem = name[0...-ext.length] + n = 1 + n += 1 while File.exist?(File.join(dir, "#{stem}-#{n}#{ext}")) + "#{stem}-#{n}#{ext}" + end + + def list_children(dir, relative) + Dir.children(dir).sort.filter_map do |entry| + full = File.join(dir, entry) + rel = relative.empty? ? entry : "#{relative}/#{entry}" + + if File.directory?(full) + { "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) } + elsif entry.downcase.end_with?(".gpx") + { "type" => "file", "name" => entry, "path" => rel } + end + end + end + end +end diff --git a/public/css/style.css b/public/css/style.css index c001470..c4dcab3 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -76,3 +76,120 @@ html, body { font-variant-numeric: tabular-nums; white-space: nowrap; } + +.gpx-toggle button { + /* Override maplibregl-ctrl-group's 29x29px square icon-button sizing — + this is a text button. */ + width: auto; + height: auto; + border: none; + background: #fff; + padding: 6px 10px; + font: inherit; + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} + +.gpx-panel { + position: fixed; + top: 0; + right: 0; + width: 300px; + max-width: 90vw; + height: 100%; + display: flex; + flex-direction: column; + background: #fff; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.15); + font-size: 13px; + z-index: 10; +} + +.gpx-panel[hidden] { + display: none; +} + +.gpx-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid #eee; +} + +.gpx-panel__close { + border: none; + background: none; + font-size: 18px; + line-height: 1; + padding: 0 4px; + cursor: pointer; +} + +.gpx-upload { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid #eee; +} + +.gpx-upload input { + font: inherit; + font-size: 13px; +} + +.gpx-upload button { + align-self: flex-start; + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.gpx-upload__status { + margin: 0; + color: #666; +} + +.gpx-tree { + overflow-y: auto; + padding: 8px 12px; +} + +.gpx-tree ul { + list-style: none; + margin: 0; + padding-left: 16px; +} + +.gpx-tree > ul { + padding-left: 0; +} + +.gpx-tree summary { + cursor: pointer; + font-weight: 600; +} + +.gpx-tree__file { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + cursor: pointer; +} + +.gpx-tree__empty { + color: #888; + font-style: italic; +} + +.maplibregl-popup-content p { + margin: 4px 0 0; +} + +.gpx-popup__time { + color: #888; + font-size: 12px; +} diff --git a/public/js/gpx.js b/public/js/gpx.js new file mode 100644 index 0000000..77cd6cb --- /dev/null +++ b/public/js/gpx.js @@ -0,0 +1,244 @@ +import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; + +const TREE_URL = "/api/gpx/tree"; +const UPLOAD_URL = "/api/gpx"; +const TRACK_COLOR = "#e63946"; + +// A MapLibre IControl: a "GPX" button that opens a side panel for +// uploading GPX files (stored server-side in a folder hierarchy) and +// toggling their tracks/waypoints on and off the map. +export class GpxPanelControl { + constructor(map) { + this._map = map; + this._visible = new Set(); // file paths currently shown on the map + this._interactiveLayers = new Set(); // point layer ids with click/hover wired up + + // setStyle() (switching the Online/Offline base map) tears down all + // custom sources and layers — put visible GPX layers back afterwards. + map.on("style.load", () => { + this._visible.forEach((path) => this._addLayers(path)); + }); + } + + onAdd() { + this._button = document.createElement("div"); + this._button.className = "maplibregl-ctrl maplibregl-ctrl-group gpx-toggle"; + this._button.innerHTML = ``; + this._button + .querySelector("button") + .addEventListener("click", () => this._togglePanel()); + + this._panel = document.getElementById("gpx-panel"); + this._treeContainer = this._panel.querySelector(".gpx-tree"); + this._uploadForm = this._panel.querySelector(".gpx-upload"); + this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); + this._panel + .querySelector(".gpx-panel__close") + .addEventListener("click", () => this._togglePanel(false)); + this._uploadForm.addEventListener("submit", (event) => this._handleUpload(event)); + + this._loadTree(); + + return this._button; + } + + onRemove() { + this._button.remove(); + } + + _togglePanel(force) { + this._panel.hidden = force === undefined ? !this._panel.hidden : !force; + } + + async _loadTree() { + const response = await fetch(TREE_URL); + const tree = await response.json(); + this._renderTree(tree); + } + + _renderTree(node) { + this._treeContainer.innerHTML = ""; + this._treeContainer.appendChild(this._renderChildren(node.children)); + } + + _renderChildren(children) { + const ul = document.createElement("ul"); + if (children.length === 0) { + const li = document.createElement("li"); + li.className = "gpx-tree__empty"; + li.textContent = "No files yet"; + ul.appendChild(li); + return ul; + } + + children.forEach((child) => { + const li = document.createElement("li"); + if (child.type === "folder") { + const details = document.createElement("details"); + details.open = true; + const summary = document.createElement("summary"); + summary.textContent = child.name; + details.appendChild(summary); + details.appendChild(this._renderChildren(child.children)); + li.appendChild(details); + } else { + const label = document.createElement("label"); + label.className = "gpx-tree__file"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = this._visible.has(child.path); + checkbox.addEventListener("change", () => this._toggleFile(child.path, checkbox.checked)); + label.appendChild(checkbox); + label.append(` ${child.name}`); + li.appendChild(label); + } + ul.appendChild(li); + }); + + return ul; + } + + _toggleFile(path, visible) { + if (visible) { + this._visible.add(path); + this._addLayers(path); + } else { + this._visible.delete(path); + this._removeLayers(path); + } + } + + _addLayers(path) { + const id = sourceId(path); + if (this._map.getSource(id)) return; + + this._map.addSource(id, { type: "geojson", data: gpxFileUrl(path) }); + this._map.addLayer({ + id: `${id}-line`, + type: "line", + source: id, + filter: ["==", ["geometry-type"], "LineString"], + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": TRACK_COLOR, "line-width": 3 }, + }); + this._map.addLayer({ + id: `${id}-points`, + type: "circle", + source: id, + filter: ["==", ["geometry-type"], "Point"], + paint: { + "circle-radius": 5, + "circle-color": TRACK_COLOR, + "circle-stroke-color": "#fff", + "circle-stroke-width": 1.5, + }, + }); + + this._registerPointInteractivity(`${id}-points`); + } + + // Registered once per layer id, ever — map.on(type, layerId, fn) is a + // delegated listener that lives on the map itself and keeps matching + // that layer id after setStyle() removes and (via _addLayers) re-adds + // it, so re-registering on every re-add would stack duplicate popups. + _registerPointInteractivity(layerId) { + if (this._interactiveLayers.has(layerId)) return; + this._interactiveLayers.add(layerId); + + this._map.on("click", layerId, (event) => { + const feature = event.features[0]; + if (!feature) return; + new Popup({ closeButton: true, maxWidth: "260px" }) + .setLngLat(event.lngLat) + .setHTML(popupHtml(feature.properties)) + .addTo(this._map); + }); + this._map.on("mouseenter", layerId, () => { + this._map.getCanvas().style.cursor = "pointer"; + }); + this._map.on("mouseleave", layerId, () => { + this._map.getCanvas().style.cursor = ""; + }); + } + + _removeLayers(path) { + const id = sourceId(path); + [`${id}-line`, `${id}-points`].forEach((layerId) => { + if (this._map.getLayer(layerId)) this._map.removeLayer(layerId); + }); + if (this._map.getSource(id)) this._map.removeSource(id); + } + + async _handleUpload(event) { + event.preventDefault(); + const formData = new FormData(this._uploadForm); + this._setStatus("Uploading…"); + + try { + const response = await fetch(UPLOAD_URL, { method: "POST", body: formData }); + if (!response.ok) throw new Error("upload failed"); + const { tree } = await response.json(); + this._renderTree(tree); + this._uploadForm.reset(); + this._setStatus("Uploaded."); + } catch (err) { + this._setStatus("Upload failed."); + } + } + + _setStatus(text) { + this._uploadStatus.hidden = false; + this._uploadStatus.textContent = text; + } +} + +function sourceId(path) { + return `gpx-${path}`; +} + +function gpxFileUrl(path) { + return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`; +} + +// properties come straight from an uploaded GPX file — untrusted input — +// so every value is escaped before landing in the popup's HTML. +function popupHtml({ name, description, time }) { + const parts = []; + if (name) parts.push(`${escapeHtml(name)}`); + if (description) parts.push(`

${linkify(description).replace(/\n/g, "
")}

`); + if (time) { + const parsed = new Date(time); + if (!Number.isNaN(parsed.getTime())) { + parts.push(`

${escapeHtml(parsed.toLocaleString())}

`); + } + } + return parts.length > 0 ? parts.join("") : "No details"; +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// Turns bare URLs in untrusted text into safe
links: the string is +// split on the URL pattern and every piece is escaped — including the URL +// itself, both as href and as link text — before any HTML is assembled, so +// nothing bypasses escaping. The URL pattern excludes quote characters, so +// a matched URL can never break out of the href="..." attribute. +const URL_PATTERN = /(https?:\/\/[^\s<>"']+)/g; + +function linkify(text) { + return text + .split(URL_PATTERN) + .map((part, i) => (i % 2 === 1 ? urlToLink(part) : escapeHtml(part))) + .join(""); +} + +function urlToLink(url) { + // Keep trailing sentence punctuation (e.g. "...place/1234.") out of the + // link itself. + const [, core, trailing] = url.match(/^(.*?)([.,;:!?]*)$/); + const href = escapeHtml(core); + return `${href}${escapeHtml(trailing)}`; +} diff --git a/public/js/map.js b/public/js/map.js index c8c6c84..c9d436d 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -4,6 +4,7 @@ import { addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; +import { GpxPanelControl } from "/js/gpx.js"; const CENTER = [17.1077, 48.1486]; const ZOOM = 13; @@ -74,6 +75,7 @@ map.addControl(new RadarControl(map), "top-right"); const layerToggle = new LayerToggleControl(applyStyle); map.addControl(layerToggle, "top-left"); layerToggle.setActive("online"); +map.addControl(new GpxPanelControl(map), "top-left"); function applyStyle(name) { layerToggle.setActive(name); diff --git a/views/index.erb b/views/index.erb index ad19e7d..4cff2eb 100644 --- a/views/index.erb +++ b/views/index.erb @@ -1 +1,17 @@
+ +