feature/render-binary #1
7 changed files with 327 additions and 94 deletions
|
@ -13,15 +13,16 @@
|
|||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="module" module-name="labyrinth-generator" />
|
||||
<orderEntry type="library" name="Maven: org.apache.pdfbox:pdfbox:2.0.20" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.apache.pdfbox:fontbox:2.0.20" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.apache.pdfbox:pdfbox:2.0.25" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.apache.pdfbox:fontbox:2.0.25" level="project" />
|
||||
<orderEntry type="library" name="Maven: commons-logging:commons-logging:1.2" level="project" />
|
||||
<orderEntry type="library" name="Maven: io.vavr:vavr:0.10.2" level="project" />
|
||||
<orderEntry type="library" name="Maven: io.vavr:vavr-match:0.10.2" level="project" />
|
||||
<orderEntry type="library" scope="PROVIDED" name="Maven: org.projectlombok:lombok:1.18.12" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.jetbrains:annotations:19.0.0" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.slf4j:slf4j-api:1.7.30" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.slf4j:slf4j-simple:1.7.30" level="project" />
|
||||
<orderEntry type="library" name="Maven: org.slf4j:slf4j-api:1.7.35" level="project" />
|
||||
<orderEntry type="library" name="Maven: ch.qos.logback:logback-classic:1.2.10" level="project" />
|
||||
<orderEntry type="library" name="Maven: ch.qos.logback:logback-core:1.2.10" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="Maven: org.junit.jupiter:junit-jupiter-api:5.6.1" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="Maven: org.apiguardian:apiguardian-api:1.1.0" level="project" />
|
||||
<orderEntry type="library" scope="TEST" name="Maven: org.opentest4j:opentest4j:1.2.0" level="project" />
|
||||
|
|
28
pom.xml
28
pom.xml
|
@ -14,6 +14,10 @@
|
|||
<artifactId>labyrinth-server</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<slf4j.version>1.7.35</slf4j.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>ch.fritteli.labyrinth</groupId>
|
||||
|
@ -35,12 +39,12 @@
|
|||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
<version>1.7.30</version>
|
||||
<version>${slf4j.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-simple</artifactId>
|
||||
<version>1.7.30</version>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>1.2.10</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
|
@ -95,12 +99,24 @@
|
|||
</distributionManagement>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>repo.gittr.ch</id>
|
||||
<url>https://repo.gittr.ch/</url>
|
||||
<id>repo.gittr.ch.releases</id>
|
||||
<url>https://repo.gittr.ch/releases/</url>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
<updatePolicy>never</updatePolicy>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
<updatePolicy>never</updatePolicy>
|
||||
</snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>repo.gittr.ch.snapshots</id>
|
||||
<url>https://repo.gittr.ch/snapshots/</url>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
<updatePolicy>never</updatePolicy>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
<updatePolicy>always</updatePolicy>
|
||||
|
|
|
@ -24,20 +24,24 @@ import org.jetbrains.annotations.Nullable;
|
|||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Slf4j
|
||||
public class LabyrinthServer {
|
||||
@NonNull
|
||||
private final HttpServer httpServer;
|
||||
@NonNull
|
||||
private final ExecutorService executorService = Executors.newCachedThreadPool();
|
||||
|
||||
public LabyrinthServer(@NonNull final ServerConfig config) throws IOException {
|
||||
public LabyrinthServer(@NonNull final ServerConfig config) throws IOException, URISyntaxException {
|
||||
this.httpServer = HttpServer.create(new InetSocketAddress(config.getAddress(), config.getPort()), 5);
|
||||
this.httpServer.createContext("/", exchange -> {
|
||||
exchange.getResponseHeaders().add("Location", "/create");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
});
|
||||
this.httpServer.createContext("/", new StaticResourcesFileHandler(this.executorService));
|
||||
this.httpServer.createContext("/create", this::handleCreate);
|
||||
this.httpServer.createContext("/render", this::handleRender);
|
||||
}
|
||||
|
@ -94,97 +98,111 @@ public class LabyrinthServer {
|
|||
public void stop() {
|
||||
log.info("Stopping server ...");
|
||||
this.httpServer.stop(5);
|
||||
this.executorService.shutdown();
|
||||
try {
|
||||
if (!this.executorService.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
log.warn("Timeout occurred while awaiting termination of executor service");
|
||||
}
|
||||
} catch (final InterruptedException e) {
|
||||
log.error("Failed to await termination of executor service", e);
|
||||
}
|
||||
log.info("Server stopped.");
|
||||
}
|
||||
|
||||
private void handleCreate(HttpExchange exchange) throws IOException {
|
||||
log.debug("Handling request to {}", exchange.getRequestURI());
|
||||
final String requestMethod = exchange.getRequestMethod();
|
||||
if (!requestMethod.equals("GET")) {
|
||||
exchange.getResponseBody().close();
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
final Map<RequestParameter, String> requestParams = this.parseQueryString(exchange.getRequestURI().getQuery());
|
||||
final int width = this.getOrDefault(requestParams.get(RequestParameter.WIDTH), Integer::valueOf, 5);
|
||||
final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 5);
|
||||
final Option<Long> idOption = requestParams.get(RequestParameter.ID).toTry().map(Long::valueOf).toOption();
|
||||
final Option<OutputType> outputOption = requestParams.get(RequestParameter.OUTPUT).flatMap(OutputType::ofString);
|
||||
final Headers responseHeaders = exchange.getResponseHeaders();
|
||||
final AtomicBoolean needsRedirect = new AtomicBoolean(false);
|
||||
final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime);
|
||||
final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(OutputType.HTML);
|
||||
if (needsRedirect.get()) {
|
||||
responseHeaders.add("Location", "/create?width=" + width + "&height=" + height + "&output=" + output.toString() + "&id=" + id);
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
final Labyrinth labyrinth = new Labyrinth(width, height, id);
|
||||
final byte[] render;
|
||||
try {
|
||||
render = output.render(labyrinth);
|
||||
} catch (Exception e) {
|
||||
responseHeaders.add("Content-type", "text/plain; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(500, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8));
|
||||
responseBody.flush();
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
responseHeaders.add("Content-type", output.getContentType());
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(render);
|
||||
responseBody.flush();
|
||||
exchange.close();
|
||||
this.executorService.submit(() -> {
|
||||
log.debug("Handling request to {}", exchange.getRequestURI());
|
||||
try {
|
||||
final String requestMethod = exchange.getRequestMethod();
|
||||
if (!requestMethod.equals("GET")) {
|
||||
exchange.getResponseBody().close();
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
final Map<RequestParameter, String> requestParams = this.parseQueryString(exchange.getRequestURI().getQuery());
|
||||
final int width = this.getOrDefault(requestParams.get(RequestParameter.WIDTH), Integer::valueOf, 5);
|
||||
final int height = this.getOrDefault(requestParams.get(RequestParameter.HEIGHT), Integer::valueOf, 5);
|
||||
final Option<Long> idOption = requestParams.get(RequestParameter.ID).toTry().map(Long::valueOf).toOption();
|
||||
final Option<OutputType> outputOption = requestParams.get(RequestParameter.OUTPUT).flatMap(OutputType::ofString);
|
||||
final Headers responseHeaders = exchange.getResponseHeaders();
|
||||
final AtomicBoolean needsRedirect = new AtomicBoolean(false);
|
||||
final long id = idOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(System::nanoTime);
|
||||
final OutputType output = outputOption.onEmpty(() -> needsRedirect.set(true)).getOrElse(OutputType.HTML);
|
||||
if (needsRedirect.get()) {
|
||||
responseHeaders.add("Location", "/create?width=" + width + "&height=" + height + "&output=" + output.toString() + "&id=" + id);
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
return;
|
||||
}
|
||||
final Labyrinth labyrinth = new Labyrinth(width, height, id);
|
||||
final byte[] render;
|
||||
try {
|
||||
render = output.render(labyrinth);
|
||||
} catch (Exception e) {
|
||||
responseHeaders.add("Content-type", "text/plain; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(500, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8));
|
||||
responseBody.flush();
|
||||
return;
|
||||
}
|
||||
responseHeaders.add("Content-type", output.getContentType());
|
||||
if (output.equals(OutputType.BINARY)) {
|
||||
responseHeaders.add("Content-disposition", "attachment; filename=\"labyrinth-" + width + "x" + height + "-" + id + ".laby\"");
|
||||
}
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(render);
|
||||
responseBody.flush();
|
||||
} catch (Exception e) {
|
||||
log.error("FSCK!", e);
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleRender(final HttpExchange exchange) throws IOException {
|
||||
try {
|
||||
log.debug("Handling request to {}", exchange.getRequestURI());
|
||||
final String requestMethod = exchange.getRequestMethod();
|
||||
if (!requestMethod.equals("POST")) {
|
||||
exchange.getResponseBody().close();
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
final byte[] bytes = exchange.getRequestBody().readAllBytes();
|
||||
@NonNull final Labyrinth labyrinth;
|
||||
this.executorService.submit(() -> {
|
||||
try {
|
||||
labyrinth = SerializerDeserializer.deserialize(bytes);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
throw e;
|
||||
}
|
||||
log.debug("Handling request to {}", exchange.getRequestURI());
|
||||
final String requestMethod = exchange.getRequestMethod();
|
||||
if (!requestMethod.equals("POST")) {
|
||||
exchange.getResponseBody().close();
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
final byte[] bytes = exchange.getRequestBody().readAllBytes();
|
||||
|
||||
OutputType output = exchange.getRequestHeaders()
|
||||
.get("Accept")
|
||||
.contains("text/html") ?
|
||||
OutputType.HTML :
|
||||
OutputType.TEXT_PLAIN;
|
||||
final byte[] render;
|
||||
final Headers responseHeaders = exchange.getResponseHeaders();
|
||||
try {
|
||||
render = output.render(labyrinth);
|
||||
} catch (Exception e) {
|
||||
responseHeaders.add("Content-type", "text/plain; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(500, 0);
|
||||
final Labyrinth labyrinth = SerializerDeserializer.deserialize(bytes);
|
||||
|
||||
final OutputType output = exchange.getRequestHeaders()
|
||||
.get("Accept")
|
||||
.contains("text/html") ?
|
||||
OutputType.HTML :
|
||||
OutputType.TEXT_PLAIN;
|
||||
final byte[] render;
|
||||
final Headers responseHeaders = exchange.getResponseHeaders();
|
||||
try {
|
||||
render = output.render(labyrinth);
|
||||
} catch (Exception e) {
|
||||
responseHeaders.add("Content-type", "text/plain; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(500, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8));
|
||||
responseBody.flush();
|
||||
return;
|
||||
}
|
||||
responseHeaders.add("Content-type", output.getContentType());
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(("Error: " + e).getBytes(StandardCharsets.UTF_8));
|
||||
responseBody.write(render);
|
||||
responseBody.flush();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
log.error("FSCK!", e);
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
responseHeaders.add("Content-type", output.getContentType());
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
final OutputStream responseBody = exchange.getResponseBody();
|
||||
responseBody.write(render);
|
||||
responseBody.flush();
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private enum RequestParameter {
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
package ch.fritteli.labyrinth.server;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.pdfbox.io.IOUtils;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
@Slf4j
|
||||
public class StaticResourcesFileHandler implements HttpHandler {
|
||||
|
||||
public static final String WEBASSETS_DIRECTORY = "webassets";
|
||||
private final ExecutorService executorService;
|
||||
|
||||
public StaticResourcesFileHandler(final ExecutorService executorService) {
|
||||
this.executorService = executorService;
|
||||
}
|
||||
|
||||
private static void redirect(@NonNull final HttpExchange exchange, @NonNull final String target) throws IOException {
|
||||
log.debug("Sending redirect to {}", target);
|
||||
exchange.getResponseHeaders().add("Location", target);
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
}
|
||||
|
||||
private static void notFound(@NonNull final HttpExchange exchange, @NonNull final String path) throws IOException {
|
||||
log.debug("Resource '{}' not found, replying with HTTP 404", path);
|
||||
exchange.getResponseHeaders().add("Content-type", "text/plain; charset=utf-8");
|
||||
exchange.sendResponseHeaders(404, 0);
|
||||
exchange.getResponseBody().write("404 - Not found".getBytes(StandardCharsets.UTF_8));
|
||||
exchange.getResponseBody().flush();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static byte[] getBytes(@NonNull final String path) throws IOException {
|
||||
final InputStream stream = StaticResourcesFileHandler.class.getClassLoader().getResourceAsStream(WEBASSETS_DIRECTORY + path);
|
||||
if (stream == null) {
|
||||
log.debug("Resource '{}' not found in classpath.", path);
|
||||
return new byte[0];
|
||||
}
|
||||
return IOUtils.toByteArray(stream);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String getMimeType(@NonNull final String path) {
|
||||
if (path.endsWith(".html")) {
|
||||
return "text/html";
|
||||
}
|
||||
if (path.endsWith(".css")) {
|
||||
return "text/css";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(@NonNull final HttpExchange exchange) throws IOException {
|
||||
this.executorService.submit(() -> {
|
||||
try {
|
||||
final URI requestURI = exchange.getRequestURI();
|
||||
final String path = requestURI.getPath();
|
||||
log.debug("Handling request to {}", path);
|
||||
if ("/".equals(path)) {
|
||||
redirect(exchange, "/index.html");
|
||||
return;
|
||||
}
|
||||
if (!path.startsWith("/")) {
|
||||
notFound(exchange, path);
|
||||
return;
|
||||
}
|
||||
final String mimeType = getMimeType(path);
|
||||
if (mimeType == null) {
|
||||
notFound(exchange, path);
|
||||
return;
|
||||
}
|
||||
final byte[] responseBytes = getBytes(path);
|
||||
if (responseBytes.length == 0) {
|
||||
notFound(exchange, path);
|
||||
return;
|
||||
}
|
||||
log.debug("Serving {}{} with mimetype {}: {} bytes", WEBASSETS_DIRECTORY, path, mimeType, responseBytes.length);
|
||||
exchange.getResponseHeaders().add("Content-type", mimeType);
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
exchange.getResponseBody().write(responseBytes);
|
||||
exchange.getResponseBody().flush();
|
||||
} catch (Exception e) {
|
||||
log.error("FSCK!", e);
|
||||
} finally {
|
||||
exchange.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
17
src/main/resources/logback.xml
Normal file
17
src/main/resources/logback.xml
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<configuration>
|
||||
<shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"/>
|
||||
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<!-- encoders are by default assigned the type
|
||||
ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
<logger name="ch.fritteli.labyrinth.server.StaticResourcesFileHandler" level="debug"/>
|
||||
</configuration>
|
32
src/main/resources/webassets/index.html
Normal file
32
src/main/resources/webassets/index.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Labyrinth Generator</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="content">
|
||||
<h1>Labyrinth Generator</h1>
|
||||
<p>Enter some values, click the "Create!" button and see what happens!</p>
|
||||
<form action="/create" method="get">
|
||||
<div class="inputs-wrapper">
|
||||
<label for="width">Width:</label><input id="width" name="width" type="number" min="1" required>
|
||||
<label for="height">Height:</label><input id="height" name="height" type="number" min="1" required>
|
||||
<label for="output">Output format:</label>
|
||||
<select id="output" name="output" required>
|
||||
<option label="HTML Document" value="html"></option>
|
||||
<option label="Plain text" value="text"></option>
|
||||
<option label="PDF Document" value="pdf"></option>
|
||||
<option label="Binary" value="binary"></option>
|
||||
</select>
|
||||
<label for="id">Seed (optional):</label><input id="id" name="id" type="number">
|
||||
</div>
|
||||
<div class="controls-wrapper">
|
||||
<button type="submit" class="primary">Create!</button>
|
||||
<button type="reset">Reset form</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
51
src/main/resources/webassets/style.css
Normal file
51
src/main/resources/webassets/style.css
Normal file
|
@ -0,0 +1,51 @@
|
|||
:root {
|
||||
--color-background: #181a1b;
|
||||
--color-foreground: #e8e6e3;
|
||||
--color-border: #5d6164;
|
||||
--color-background-highlight: #292b2c;
|
||||
--color-foreground-highlight: #f8f7f4;
|
||||
--color-border-highlight: #6e7275;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
font-family: sans-serif;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body, button, input, select {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
button, input, select {
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
button:active, input:active, select:active,
|
||||
button:focus, input:focus, select:focus,
|
||||
button:hover, input:hover, select:hover {
|
||||
background-color: var(--color-background-highlight);
|
||||
border-color: var(--color-border-highlight);
|
||||
color: var(--color-foreground-highlight);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: #ffcc00;
|
||||
border-color: #eebb00;
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.inputs-wrapper {
|
||||
display: grid;
|
||||
grid-row-gap: 1em;
|
||||
grid-template-columns: 0.5fr 1fr;
|
||||
}
|
Loading…
Reference in a new issue