diff --git a/client/src/assets/covers/formaloversum.png b/client/src/assets/covers/formaloversum.png deleted file mode 100644 index df9723d..0000000 Binary files a/client/src/assets/covers/formaloversum.png and /dev/null differ diff --git a/client/src/assets/covers/nng.png b/client/src/assets/covers/nng.png deleted file mode 100644 index 69b0df5..0000000 Binary files a/client/src/assets/covers/nng.png and /dev/null differ diff --git a/client/src/components/inventory.tsx b/client/src/components/inventory.tsx index 896b1f7..a358e5b 100644 --- a/client/src/components/inventory.tsx +++ b/client/src/components/inventory.tsx @@ -86,7 +86,7 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine {[...modifiedItems].sort( // For lemas, sort entries `available > disabled > locked` // otherwise alphabetically - (x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) + (x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName) ).filter(item => !item.hidden && ((tab ?? categories[0]) == item.category)).map((item, i) => { return {openDoc({name: item.name, type: docType})}} diff --git a/client/src/components/landing_page.tsx b/client/src/components/landing_page.tsx index 9b50ce7..c36730a 100644 --- a/client/src/components/landing_page.tsx +++ b/client/src/components/landing_page.tsx @@ -7,12 +7,12 @@ import '@fontsource/roboto/500.css'; import '@fontsource/roboto/700.css'; import '../css/landing_page.css' -import coverRobo from '../assets/covers/formaloversum.png' -import coverNNG from '../assets/covers/nng.png' import bgImage from '../assets/bg.jpg' import Markdown from './markdown'; import {PrivacyPolicyPopup} from './popup/privacy_policy' +import { GameTile, useGetGameInfoQuery } from '../state/api' +import path from 'path'; const flag = { 'Dutch': '🇳🇱', @@ -33,47 +33,42 @@ function GithubIcon({url='https://github.com'}) { } -function GameTile({ - title, - gameId, - intro, // Catchy intro phrase. - image=null, - worlds='?', - levels='?', - prereq='–', // Optional list of games that this game builds on. Use markdown. - description, // Longer description. Supports Markdown. - language}) { +function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) { let navigate = useNavigate(); const routeChange = () =>{ navigate(gameId); } + if (typeof data === 'undefined') { + return <> + } + return
-
{title}
-
{intro} +
{data.title}
+
{data.short}
- { image ? :
} -
{description}
+ { data.image ? :
} +
{data.long}
- + - + - + - +
Prerequisites{prereq}{data.prerequisites.join(', ')}
Worlds{worlds}{data.worlds}
Levels{levels}{data.levels}
Language{flag[language]}{data.languages.map((lan) => flag[lan]).join(', ')}
@@ -89,6 +84,56 @@ function LandingPage() { const openImpressum = () => setImpressum(true); const closeImpressum = () => setImpressum(false); + // const [allGames, setAllGames] = React.useState([]) + // const [allTiles, setAllTiles] = React.useState([]) + + // const getTiles=()=>{ + // fetch('featured_games.json', { + // headers : { + // 'Content-Type': 'application/json', + // 'Accept': 'application/json' + // } + // } + // ).then(function(response){ + // return response.json() + // }).then(function(data) { + // setAllGames(data.featured_games) + + // }) + // } + + // React.useEffect(()=>{ + // getTiles() + // },[]) + + // React.useEffect(()=>{ + + // Promise.allSettled( + // allGames.map((gameId) => ( + // fetch(`data/g/${gameId}/game.json`).catch(err => {return undefined}))) + // ).then(responses => + // responses.forEach((result) => console.log(result))) + // // Promise.all(responses.map(res => { + // // if (res.status == "fulfilled") { + // // console.log(res.value.json()) + // // return res.value.json() + // // } else { + // // return undefined + // // } + // // })) + // // ).then(allData => { + // // setAllTiles(allData.map(data => data?.tile)) + // // }) + // },[allGames]) + + // TODO: I would like to read the supported games list form a JSON, + // Then load all these games in + // + let allGames = [ + "leanprover-community/nng4", + "djvelleman/stg4", + "hhu-adam/robo"] + let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile)) return
@@ -105,8 +150,19 @@ function LandingPage() {
- - No Games loaded. Use http://localhost:3000/#/g/local/FOLDER to open a + game directly from a local folder. +

+ : allGames.map((id, i) => ( + + )) + } + {/* - - + */}
diff --git a/client/src/components/level.tsx b/client/src/components/level.tsx index acce7cb..1246398 100644 --- a/client/src/components/level.tsx +++ b/client/src/components/level.tsx @@ -34,6 +34,7 @@ import { DualEditor } from './infoview/main' import { GameHint } from './infoview/rpc_api' import { DeletedHints, Hint, Hints } from './hints' import { PrivacyPolicyPopup } from './popup/privacy_policy' +import path from 'path'; import '@fontsource/roboto/300.css' import '@fontsource/roboto/400.css' @@ -467,6 +468,11 @@ function Introduction({impressum, setImpressum}) { const gameInfo = useGetGameInfoQuery({game: gameId}) + const {worldId} = useContext(WorldLevelIdContext) + + let image: string = gameInfo.data?.worlds.nodes[worldId].image + + const toggleImpressum = () => { setImpressum(!impressum) } @@ -480,7 +486,12 @@ function Introduction({impressum, setImpressum}) { : -
+
+ {image && + + } + +
} diff --git a/client/src/css/level.css b/client/src/css/level.css index faa2c0d..01d36b6 100644 --- a/client/src/css/level.css +++ b/client/src/css/level.css @@ -336,6 +336,16 @@ td code { background-color: #eee; } +.world-image-container { + display: flex; + flex-direction: column; + justify-content: center; +} + +.world-image-container img { + object-fit: contain; +} + .typewriter-interface .proof { background-color: #fff; } diff --git a/client/src/state/api.ts b/client/src/state/api.ts index a37ca5e..2bdbe8b 100644 --- a/client/src/state/api.ts +++ b/client/src/state/api.ts @@ -3,14 +3,28 @@ */ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' + +export interface GameTile { + title: string + short: string + long: string + languages: Array + prerequisites: Array + worlds: number + levels: number + image: string +} + export interface GameInfo { title: null|string, introduction: null|string, info: null|string, - worlds: null|{nodes: {[id:string]: {id: string, title: string, introduction: string}}, edges: string[][]}, + worlds: null|{nodes: {[id:string]: {id: string, title: string, introduction: string, image: string}}, edges: string[][]}, worldSize: null|{[key: string]: number}, authors: null|string[], conclusion: null|string, + tile: null|GameTile, + image: null|string } export interface InventoryTile { @@ -36,7 +50,8 @@ export interface LevelInfo { lemmaTab: null|string, statementName: null|string, displayName: null|string, - template: null|string + template: null|string, + image: null|string } /** Used to display the inventory on the welcome page */ @@ -59,19 +74,19 @@ interface Doc { // Define a service using a base URL and expected endpoints export const apiSlice = createApi({ reducerPath: 'gameApi', - baseQuery: fetchBaseQuery({ baseUrl: window.location.origin + "/api" }), + baseQuery: fetchBaseQuery({ baseUrl: window.location.origin + "/data" }), endpoints: (builder) => ({ getGameInfo: builder.query({ - query: ({game}) => `${game}/game`, + query: ({game}) => `${game}/game.json`, }), loadLevel: builder.query({ - query: ({game, world, level}) => `${game}/level/${world}/${level}`, + query: ({game, world, level}) => `${game}/level__${world}__${level}.json`, }), loadInventoryOverview: builder.query({ - query: ({game}) => `${game}/inventory`, + query: ({game}) => `${game}/inventory.json`, }), loadDoc: builder.query({ - query: ({game, type, name}) => `${game}/doc/${type}/${name}`, + query: ({game, type, name}) => `${game}/doc__${type}__${name}.json`, }), }), }) diff --git a/server/GameServer/Commands.lean b/server/GameServer/Commands.lean index d6b9ea5..a7fafc8 100644 --- a/server/GameServer/Commands.lean +++ b/server/GameServer/Commands.lean @@ -46,7 +46,9 @@ elab "Title" t:str : command => do match ← getCurLayer with | .Level => modifyCurLevel fun level => pure {level with title := t.getString} | .World => modifyCurWorld fun world => pure {world with title := t.getString} - | .Game => modifyCurGame fun game => pure {game with title := t.getString} + | .Game => modifyCurGame fun game => pure {game with + title := t.getString + tile := {game.tile with title := t.getString}} /-- Define the introduction of the current game/world/level. -/ elab "Introduction" t:str : command => do @@ -58,10 +60,32 @@ elab "Introduction" t:str : command => do /-- Define the info of the current game. Used for e.g. credits -/ elab "Info" t:str : command => do match ← getCurLayer with - | .Level => pure () - | .World => pure () + | .Level => + logError "Can't use `Info` in a level!" + pure () + | .World => + logError "Can't use `Info` in a world" + pure () | .Game => modifyCurGame fun game => pure {game with info := t.getString} +/-- Provide the location of the image for the current game/world/level. +Paths are relative to the lean project's root. -/ +elab "Image" t:str : command => do + let file := t.getString + if not <| ← System.FilePath.pathExists file then + logWarningAt t s!"Make sure the cover image '{file}' exists." + if not <| file.startsWith "images/" then + logWarningAt t s!"The file name should start with `images/`. Make sure all images are in that folder." + + match ← getCurLayer with + | .Level => + logWarning "Level-images not implemented yet" -- TODO + modifyCurLevel fun level => pure {level with image := file} + | .World => + modifyCurWorld fun world => pure {world with image := file} + | .Game => + logWarning "Main image of the game not implemented yet" -- TODO + modifyCurGame fun game => pure {game with image := file} /-- Define the conclusion of the current game or current level if some building a level. -/ @@ -71,6 +95,38 @@ elab "Conclusion" t:str : command => do | .World => modifyCurWorld fun world => pure {world with conclusion := t.getString} | .Game => modifyCurGame fun game => pure {game with conclusion := t.getString} +/-- A list of games that should be played before this one. Example `Prerequisites "NNG" "STG"`. -/ +elab "Prerequisites" t:str* : command => do + modifyCurGame fun game => pure {game with + tile := {game.tile with prerequisites := t.map (·.getString) |>.toList}} + +/-- Short caption for the game (1 sentence) -/ +elab "CaptionShort" t:str : command => do + modifyCurGame fun game => pure {game with + tile := {game.tile with short := t.getString}} + +/-- More detailed description what the game is about (2-4 sentences). -/ +elab "CaptionLong" t:str : command => do + modifyCurGame fun game => pure {game with + tile := {game.tile with long := t.getString}} + +/-- A list of Languages the game is translated to. For example `Languages "German" "English"`. +NOTE: For the time being, only a single language is supported. + -/ +elab "Languages" t:str* : command => do + modifyCurGame fun game => pure {game with + tile := {game.tile with languages := t.map (·.getString) |>.toList}} + +/-- The Image of the game (optional). TODO: Not impementeds -/ +elab "CoverImage" t:str : command => do + let file := t.getString + if not <| ← System.FilePath.pathExists file then + logWarningAt t s!"Make sure the cover image '{file}' exists." + if not <| file.startsWith "images/" then + logWarningAt t s!"The file name should start with `images/`. Make sure all images are in that folder." + + modifyCurGame fun game => pure {game with + tile := {game.tile with image := file}} /-! # Inventory @@ -626,6 +682,27 @@ elab "Template" tacs:tacticSeq : tactic => do modifyLevel (←getCurLevelId) fun level => do return {level with template := s!"{template}"} + + +open IO.FS System FilePath in +/-- Copies the folder `images/` to `.lake/gamedata/images/` -/ +def copyImages : IO Unit := do + let target : FilePath := ".lake" / "gamedata" + if ← FilePath.pathExists "images" then + for file in ← walkDir "images" do + let outFile := target.join file + -- create the directories + if ← file.isDir then + createDirAll outFile + else + if let some parent := outFile.parent then + createDirAll parent + -- copy file + let content ← readBinFile file + writeBinFile outFile content + + + -- TODO: Notes for testing if a declaration has the simp attribute -- -- Test: From zulip @@ -647,7 +724,7 @@ elab "Template" tacs:tacticSeq : tactic => do #eval IO.FS.createDirAll ".lake/gamedata/" -- TODO: register all of this as ToJson instance? -def saveGameData (allItemsByType : HashMap InventoryType (HashSet Name)) : CommandElabM Unit:= do +def saveGameData (allItemsByType : HashMap InventoryType (HashSet Name)) : CommandElabM Unit := do let game ← getCurGame let env ← getEnv let path : System.FilePath := s!"{← IO.currentDir}" / ".lake" / "gamedata" @@ -656,6 +733,9 @@ def saveGameData (allItemsByType : HashMap InventoryType (HashSet Name)) : Comma IO.FS.removeDirAll path IO.FS.createDirAll path + -- copy the images folder + copyImages + for (worldId, world) in game.worlds.nodes.toArray do for (levelId, level) in world.levels.toArray do IO.FS.writeFile (path / s!"level__{worldId}__{levelId}.json") (toString (toJson (level.toInfo env))) @@ -848,8 +928,12 @@ elab "MakeGame" : command => do -- Items that should not be displayed in inventory let mut hiddenItems : HashSet Name := {} + let allWorlds := game.worlds.nodes.toArray + let nrWorlds := allWorlds.size + let mut nrLevels := 0 + -- Calculate which "items" are used/new in which world - for (worldId, world) in game.worlds.nodes.toArray do + for (worldId, world) in allWorlds do let mut usedItems : HashSet Name := {} let mut newItems : HashSet Name := {} for inventoryType in #[.Tactic, .Definition, .Lemma] do @@ -888,9 +972,12 @@ elab "MakeGame" : command => do -- logInfo m!"{worldId} uses: {usedItems.toList}" -- logInfo m!"{worldId} introduces: {newItems.toList}" + -- Moreover, count the number of levels in the game + nrLevels := nrLevels + world.levels.toArray.size + /- for each "item" this is a HashSet of `worldId`s that introduce this item -/ let mut worldsWithNewItem : HashMap Name (HashSet Name) := {} - for (worldId, _world) in game.worlds.nodes.toArray do + for (worldId, _world) in allWorlds do for newItem in newItemsInWorld.findD worldId {} do worldsWithNewItem := worldsWithNewItem.insert newItem $ (worldsWithNewItem.findD newItem {}).insert worldId @@ -902,7 +989,7 @@ elab "MakeGame" : command => do let mut dependencyReasons : HashMap (Name × Name) (HashSet Name) := {} -- Calculate world dependency graph `game.worlds` - for (dependentWorldId, _dependentWorld) in game.worlds.nodes.toArray do + for (dependentWorldId, _dependentWorld) in allWorlds do let mut dependsOnWorlds : HashSet Name := {} -- Adding manual dependencies that were specified via the `Dependency` command. for (sourceId, targetId) in game.worlds.edges do @@ -953,11 +1040,21 @@ elab "MakeGame" : command => do logError m!"{w1} depends on {w2} because of {items.toList}." else worldDependsOnWorlds ← removeTransitive worldDependsOnWorlds + + -- need to delete all existing edges as they are already present in `worldDependsOnWorlds`. + modifyCurGame fun game => + pure {game with worlds := {game.worlds with edges := Array.empty}} + for (dependentWorldId, worldIds) in worldDependsOnWorlds.toArray do modifyCurGame fun game => pure {game with worlds := {game.worlds with edges := game.worlds.edges.append (worldIds.toArray.map fun wid => (wid, dependentWorldId))}} + -- Add the number of levels and worlds to the tile for the landing page + modifyCurGame fun game => pure {game with tile := {game.tile with + levels := nrLevels + worlds := nrWorlds }} + -- Apparently we need to reload `game` to get the changes to `game.worlds` we just made let game ← getCurGame diff --git a/server/GameServer/EnvExtensions.lean b/server/GameServer/EnvExtensions.lean index bcef5cc..a1e15f3 100644 --- a/server/GameServer/EnvExtensions.lean +++ b/server/GameServer/EnvExtensions.lean @@ -265,7 +265,9 @@ structure GameLevel where lemmas: InventoryInfo := default /-- A proof template that is printed in an empty editor. -/ template: Option String := none - deriving Inhabited, Repr + /-- The image for this level. -/ + image : String := default +deriving Inhabited, Repr /-- Json-encodable version of `GameLevel` Fields: @@ -286,6 +288,7 @@ structure LevelInfo where displayName : Option String statementName : Option String template : Option String + image: Option String deriving ToJson, FromJson def GameLevel.toInfo (lvl : GameLevel) (env : Environment) : LevelInfo := @@ -315,6 +318,7 @@ def GameLevel.toInfo (lvl : GameLevel) (env : Environment) : LevelInfo := -- Note: we could call `.find!` because we check in `Statement` that the -- lemma doc must exist. template := lvl.template + image := lvl.image } /-! ## World -/ @@ -331,17 +335,46 @@ structure World where conclusion : String := default /-- The levels of the world. -/ levels: HashMap Nat GameLevel := default + /-- The introduction image of the world. -/ + image: String := default deriving Inhabited instance : ToJson World := ⟨ fun world => Json.mkObj [ ("name", toJson world.name), ("title", world.title), - ("introduction", world.introduction)] + ("introduction", world.introduction), + ("image", world.image)] ⟩ /-! ## Game -/ +/-- A tile as they are displayed on the servers landing page. -/ +structure GameTile where + /-- The title of the game -/ + title: String + /-- One catch phrase about the game -/ + short: String := default + /-- One paragraph description what the game is about -/ + long: String := default + /-- List of languages the game supports + + TODO: What's the expectected format + TODO: Must be a list with a single language currently + -/ + languages: List String := default + /-- A list of games which this one builds upon -/ + prerequisites: List String := default + /-- Number of worlds in the game -/ + worlds: Nat := default + /-- Number of levels in the game -/ + levels: Nat := default + /-- A cover image of the game + + TODO: What's the format? -/ + image: String := default +deriving Inhabited, ToJson + structure Game where /-- Internal name of the game. -/ name : Name @@ -356,6 +389,10 @@ structure Game where /-- TODO: currently unused. -/ authors : List String := default worlds : Graph Name World := default + /-- The tile displayed on the server's landing page. -/ + tile : GameTile := default + /-- The path to the background image of the world. -/ + image : String := default deriving Inhabited, ToJson def getGameJson (game : «Game») : Json := Id.run do diff --git a/server/GameServer/RpcHandlers.lean b/server/GameServer/RpcHandlers.lean index ae71a25..54ac6b1 100644 --- a/server/GameServer/RpcHandlers.lean +++ b/server/GameServer/RpcHandlers.lean @@ -126,7 +126,7 @@ def findHints (goal : MVarId) (doc : FileWorker.EditableDocument) (initParams : then let userFVars := hintFVars.map fun v => bij.forward.findD v.fvarId! v.fvarId! let text := (← evalHintMessage hint.text) (userFVars.map Expr.fvar) - let ctx := {env := ← getEnv, mctx := ← getMCtx, lctx := ← getLCtx, opts := {}} + let ctx := {env := ← getEnv, mctx := ← getMCtx, lctx := lctx, opts := {}} let text ← (MessageData.withContext ctx text).toString return some { text := text, hidden := hint.hidden } else return none diff --git a/server/import.mjs b/server/import.mjs index 6643cd7..1701b14 100644 --- a/server/import.mjs +++ b/server/import.mjs @@ -108,12 +108,11 @@ async function doImport (owner, repo, id) { } catch (e) { progress[id].output += `Error: ${e.toString()}\n${e.stack}` } finally { - // if (artifactId) { - // // fs.rmSync(`tmp/artifact_${artifactId}.zip`, {force: true, recursive: true}); - // // fs.rmSync(`tmp/artifact_${artifactId}`, {force: true, recursive: true}); - // // fs.rmSync(`tmp/artifact_${artifactId}_inner`, {force: true, recursive: true}); - // // fs.rmSync(`tmp/archive_${artifactId}.tar`, {force: true, recursive: true}); - // } + // clean-up temp. files + if (artifactId) { + fs.rmSync(`${__dirname}/../games/tmp/${owner}_${repo}_${artifactId}.zip`, {force: true, recursive: false}); + fs.rmSync(`${__dirname}/../games/tmp/${owner}_${repo}_${artifactId}`, {force: true, recursive: true}); + } progress[id].done = true } await new Promise(resolve => setTimeout(resolve, 10000)) diff --git a/server/index.mjs b/server/index.mjs index 0f2ce06..743af10 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -34,34 +34,15 @@ var router = express.Router(); router.get('/import/status/:owner/:repo', importStatus) router.get('/import/trigger/:owner/:repo', importTrigger) -function loadJson(req, filename) { - const owner = req.params.owner; - const repo = req.params.repo - return JSON.parse(fs.readFileSync(path.join(getGameDir(owner,repo),".lake","gamedata",filename))) -} - -router.get("/api/g/:owner/:repo/game", (req, res) => { - res.send(loadJson(req, `game.json`)); -}); - -router.get("/api/g/:owner/:repo/inventory", (req, res) => { - res.send(loadJson(req, `inventory.json`)); -}); - -router.get("/api/g/:owner/:repo/level/:world/:level", (req, res) => { - const world = req.params.world; - const level = req.params.level; - res.send(loadJson(req, `level__${world}__${level}.json`)); -}); - -router.get("/api/g/:owner/:repo/doc/:type/:name", (req, res) => { - const type = req.params.type; - const name = req.params.name; - res.send(loadJson(req, `doc__${type}__${name}.json`)); -}); - const server = app - .use(express.static(path.join(__dirname, '../client/dist/'))) + .use(express.static(path.join(__dirname, '../client/dist/'))) // TODO: add a dist folder from inside the game + .use('/data/g/:owner/:repo/*', (req, res, next) => { + const owner = req.params.owner; + const repo = req.params.repo + const filename = req.params[0]; + req.url = filename; + express.static(path.join(getGameDir(owner,repo),".lake","gamedata"))(req, res, next); + }) .use('/', router) .listen(PORT, () => console.log(`Listening on ${PORT}`)); @@ -84,13 +65,13 @@ function getGameDir(owner, repo) { if (owner == 'local') { if(!isDevelopment) { console.error(`No local games in production mode.`) - return + return "" } } else { if(!fs.existsSync(path.join(__dirname, '..', 'games'))) { console.error(`Did not find the following folder: ${path.join(__dirname, '..', 'games')}`) console.error('Did you already import any games?') - return + return "" } } @@ -100,7 +81,7 @@ function getGameDir(owner, repo) { if(!fs.existsSync(game_dir)) { console.error(`Game '${game_dir}' does not exist!`) - return + return "" } return game_dir; @@ -114,7 +95,7 @@ function startServerProcess(owner, repo) { let serverProcess if (isDevelopment) { let args = ["--server", game_dir] - serverProcess = cp.spawn("./gameserver", args, + serverProcess = cp.spawn("./gameserver", args, // TODO: find gameserver inside the games { cwd: path.join(__dirname, "./.lake/build/bin/") }) } else { serverProcess = cp.spawn("./bubblewrap.sh", diff --git a/server/lake-manifest.json b/server/lake-manifest.json index c73e6a6..2554a52 100644 --- a/server/lake-manifest.json +++ b/server/lake-manifest.json @@ -4,10 +4,10 @@ [{"url": "https://github.com/leanprover/std4.git", "type": "git", "subDir": null, - "rev": "a652e09bd81bcb43ea132d64ecc16580b0c7fa50", + "rev": "2e4a3586a8f16713f16b2d2b3af3d8e65f3af087", "name": "std", "manifestFile": "lake-manifest.json", - "inputRev": "v4.3.0-rc2", + "inputRev": "v4.3.0", "inherited": false, "configFile": "lakefile.lean"}], "name": "GameServer", diff --git a/server/lean-toolchain b/server/lean-toolchain index 24a3cdb..5cadc9d 100644 --- a/server/lean-toolchain +++ b/server/lean-toolchain @@ -1 +1 @@ -leanprover/lean4:v4.3.0-rc2 +leanprover/lean4:v4.3.0 diff --git a/vite.config.ts b/vite.config.ts index 6ec0c2d..a647c0e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ '/import': { target: 'http://localhost:8080', }, - '/api': { + '/data': { target: 'http://localhost:8080', }, }