diff --git a/.gitignore b/.gitignore index 6ac465db..5a54815d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ /.idea/ +.env \ No newline at end of file diff --git a/HelloFX.iml b/HelloFX.iml new file mode 100644 index 00000000..9fa68966 --- /dev/null +++ b/HelloFX.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index c40f667e..1d194229 100644 --- a/pom.xml +++ b/pom.xml @@ -5,18 +5,30 @@ 4.0.0 com.example - javafx + HelloFX 1.0-SNAPSHOT 25 UTF-8 - 6.0.0 + 5.10.0 3.27.6 5.20.0 25 + + + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-fxml + ${javafx.version} + org.junit.jupiter junit-jupiter @@ -36,16 +48,33 @@ test - org.openjfx - javafx-controls - ${javafx.version} + io.github.cdimascio + dotenv-java + 3.2.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.17.2 + + + com.fasterxml.jackson.core + jackson-annotations + 2.17.2 + + + org.wiremock + wiremock + 4.0.0-beta.15 + test org.openjfx - javafx-fxml + javafx-swing ${javafx.version} + @@ -54,15 +83,28 @@ 0.0.8 com.example.HelloFX - - - - javafx - true - true - true + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 25 + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + false + + **/*Test.java + + diff --git a/src/main/java/com/example/HelloController.java b/src/main/java/com/example/HelloController.java index fdd160a0..626b330c 100644 --- a/src/main/java/com/example/HelloController.java +++ b/src/main/java/com/example/HelloController.java @@ -1,22 +1,176 @@ package com.example; +import javafx.application.Platform; +import javafx.collections.ListChangeListener; import javafx.fxml.FXML; -import javafx.scene.control.Label; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import javafx.stage.Window; + +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; -/** - * Controller layer: mediates between the view (FXML) and the model. - */ public class HelloController { - private final HelloModel model = new HelloModel(); + private HelloModel model; + + @FXML private TextField messageField; + @FXML private VBox chatBox; + @FXML private ScrollPane chatScroll; + @FXML private Label statusLabel; + + // Används av HelloFX för injection + public void setModel(HelloModel model) { + this.model = model; + attachListeners(); + } + + public void setConnection(NtfyConnection connection) { + if (connection == null) throw new IllegalArgumentException("Connection cannot be null"); + this.model = new HelloModel(connection); + attachListeners(); + } + + private void attachListeners() { + model.getMessages().addListener((ListChangeListener) change -> { + Platform.runLater(() -> { + while (change.next()) { + if (change.wasAdded()) { + for (NtfyMessageDto msg : change.getAddedSubList()) { + boolean sentByUser = msg.clientId() != null && msg.clientId().equals(model.getClientId()); + + if (msg.imageUrl() != null && !msg.imageUrl().isBlank()) { + addImageBubbleFromUrl(msg.imageUrl(), sentByUser, msg.time()); + } else if (msg.message() != null && !msg.message().isBlank()) { + addMessageBubble(msg.message(), sentByUser, msg.time()); + } + } + } + } + }); + }); + } - @FXML - private Label messageLabel; @FXML private void initialize() { - if (messageLabel != null) { - messageLabel.setText(model.getGreeting()); + if (messageField != null) { + messageField.setOnAction(e -> handleSend()); + } + + chatBox.heightProperty().addListener((obs, oldVal, newVal) -> chatScroll.setVvalue(1.0)); + } + + @FXML + private void handleSend() { + if (model == null) return; + String message = messageField.getText().trim(); + if (!message.isEmpty()) { + model.sendMessage(message); + messageField.clear(); + model.setMessageToSend(""); // Clear property for bound text fields + } + } + + @FXML + private void handleSendImage() { + if (model == null) return; + + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Välj en bild att skicka"); + fileChooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("Bildfiler", "*.png", "*.jpg", "*.jpeg", "*.gif") + ); + + Window window = messageField.getScene() != null ? messageField.getScene().getWindow() : null; + File imageFile = fileChooser.showOpenDialog(window); + + if (imageFile != null) { + if (!imageFile.exists()) { + showStatus("Fil finns inte: " + imageFile.getName()); + return; + } + + boolean success = model.sendImage(imageFile); + if (!success) { + showStatus("Misslyckades att skicka bilden: " + imageFile.getName()); + } } } + + private void addMessageBubble(String text, boolean isSentByUser, long timestamp) { + if (text == null || text.isBlank()) return; + + Label messageLabel = new Label(text); + messageLabel.setWrapText(true); + messageLabel.setMaxWidth(400); + messageLabel.getStyleClass().add(isSentByUser ? "sent-message" : "received-message"); + + String timeString = Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalTime().toString().substring(0, 5); + Label timeLabel = new Label(timeString); + timeLabel.getStyleClass().add("timestamp"); + + VBox bubble = new VBox(messageLabel, timeLabel); + bubble.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT); + + HBox container = new HBox(bubble); + container.setPadding(new Insets(5)); + container.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT); + + chatBox.getChildren().add(container); + } + + private void addImageBubbleFromUrl(String url, boolean isSentByUser, long timestamp) { + if (url == null || url.isBlank()) return; + + ImageView imageView = new ImageView(new Image(url, true)); + imageView.setFitWidth(200); + imageView.setPreserveRatio(true); + imageView.setSmooth(true); + + imageView.setOnMouseClicked(e -> { + Stage stage = new Stage(); + ImageView bigView = new ImageView(new Image(url)); + bigView.setPreserveRatio(true); + bigView.setFitWidth(800); + + ScrollPane sp = new ScrollPane(bigView); + sp.setFitToWidth(true); + + stage.setScene(new Scene(sp, 900, 700)); + stage.setTitle("Bildvisning"); + stage.show(); + }); + + String timeString = Instant.ofEpochSecond(timestamp) + .atZone(ZoneId.systemDefault()) + .toLocalTime().toString().substring(0, 5); + Label timeLabel = new Label(timeString); + timeLabel.getStyleClass().add("timestamp"); + + VBox box = new VBox(imageView, timeLabel); + box.setSpacing(3); + box.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT); + + HBox wrapper = new HBox(box); + wrapper.setPadding(new Insets(5)); + wrapper.setAlignment(isSentByUser ? Pos.CENTER_RIGHT : Pos.CENTER_LEFT); + + chatBox.getChildren().add(wrapper); + } + + public void showStatus(String text) { + Platform.runLater(() -> statusLabel.setText(text)); + } } diff --git a/src/main/java/com/example/HelloFX.java b/src/main/java/com/example/HelloFX.java index 96bdc5ca..2a666712 100644 --- a/src/main/java/com/example/HelloFX.java +++ b/src/main/java/com/example/HelloFX.java @@ -2,24 +2,78 @@ import javafx.application.Application; import javafx.fxml.FXMLLoader; -import javafx.scene.Parent; import javafx.scene.Scene; import javafx.stage.Stage; +import io.github.cdimascio.dotenv.Dotenv; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; public class HelloFX extends Application { + private NtfyConnection connection; + private ImageServer imageServer; + @Override - public void start(Stage stage) throws Exception { + public void start(Stage stage) { + + Dotenv dotenv = Dotenv.load(); + String hostName = dotenv.get("HOST_NAME"); + if (hostName == null || hostName.isBlank()) { + showError("Configuration error", "HOST_NAME is not set in .env"); + return; + } + + connection = new NtfyConnectionImpl(hostName); + + try { + Path imageDir = Path.of("images"); + Files.createDirectories(imageDir); + imageServer = new ImageServer(8081, imageDir); + } catch (IOException e) { + showError("Image server error", "Could not start local image server:\n" + e.getMessage()); + return; + } + FXMLLoader fxmlLoader = new FXMLLoader(HelloFX.class.getResource("hello-view.fxml")); - Parent root = fxmlLoader.load(); - Scene scene = new Scene(root, 640, 480); - stage.setTitle("Hello MVC"); + Scene scene; + try { + scene = new Scene(fxmlLoader.load(), 600, 400); + } catch (IOException e) { + showError("FXML loading error", "Could not load GUI:\n" + e.getMessage()); + return; + } + + HelloController controller = fxmlLoader.getController(); + controller.setConnection(connection); + + stage.setTitle("HelloFX Chat"); stage.setScene(scene); stage.show(); + + stage.setOnCloseRequest(event -> { + System.out.println("🛑 Application closing..."); + + try { + if (connection != null) connection.stopReceiving(); + } catch (Exception e) { + e.printStackTrace(); + } + + try { + if (imageServer != null) imageServer.stop(); + } catch (Exception e) { + e.printStackTrace(); + } + }); + } + + private void showError(String title, String message) { + System.err.println("❌ " + title + ": " + message); } public static void main(String[] args) { launch(); } - -} \ No newline at end of file +} diff --git a/src/main/java/com/example/HelloModel.java b/src/main/java/com/example/HelloModel.java index 385cfd10..36caaaa2 100644 --- a/src/main/java/com/example/HelloModel.java +++ b/src/main/java/com/example/HelloModel.java @@ -1,15 +1,185 @@ package com.example; -/** - * Model layer: encapsulates application data and business logic. - */ +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.UUID; + public class HelloModel { + + private final NtfyConnection connection; + private final ObservableList messages = FXCollections.observableArrayList(); + private final StringProperty messageToSend = new SimpleStringProperty(); + private final String clientId = UUID.randomUUID().toString(); + public static final String DEFAULT_TOPIC = "MartinsTopic"; + + private final boolean headless; // <-- För tester utan JavaFX + + // Konstruktor för GUI + public HelloModel(NtfyConnection connection) { + this(connection, false); + } + + // Konstruktor för headless eller GUI + public HelloModel(NtfyConnection connection, boolean headless) { + if (connection == null) throw new IllegalArgumentException("Connection cannot be null"); + this.connection = connection; + this.headless = headless; + receiveMessages(); + } + + public String getClientId() { + return clientId; + } + + public ObservableList getMessages() { + return messages; + } + + public StringProperty messageToSendProperty() { + return messageToSend; + } + + public void setMessageToSend(String message) { + messageToSend.set(message); + } + /** - * Returns a greeting based on the current Java and JavaFX versions. + * Skickar ett textmeddelande via NtfyConnection och lägger till det i lokal lista. */ - public String getGreeting() { - String javaVersion = System.getProperty("java.version"); - String javafxVersion = System.getProperty("javafx.version"); - return "Hello, JavaFX " + javafxVersion + ", running on Java " + javaVersion + "."; + public void sendMessage(String messageText) { + if (messageText == null || messageText.isBlank()) return; + + connection.send(messageText); // plain text + addMessageSafely(new NtfyMessageDto( + UUID.randomUUID().toString(), + System.currentTimeMillis() / 1000, + "message", + DEFAULT_TOPIC, + messageText, + null, + null, + clientId + )); + messageToSend.set(""); // Clear bound property } + + /** + * Skickar en bildfil via lokal uppladdning och notifikation. + */ + public boolean sendImage(File imageFile) { + if (imageFile == null || !imageFile.exists()) return false; + try { + String imageUrl = uploadToLocalServer(imageFile); + + // Skicka bild-notis via Ntfy + connection.send("Bild: " + imageFile.getName() + "\n![Bild](" + imageUrl + ")"); + + // Lägg till i lokal lista + addMessageSafely(new NtfyMessageDto( + UUID.randomUUID().toString(), + System.currentTimeMillis() / 1000, + "message", + DEFAULT_TOPIC, + null, + null, + imageUrl, + clientId + )); + return true; + } catch (IOException e) { + e.printStackTrace(); + return false; + } + } + + /** + * Startar mottagning av meddelanden från NtfyConnection. + */ + private void receiveMessages() { + connection.receive(dto -> { + if (dto == null) return; + + // Ignorera egna meddelanden + if (clientId.equals(dto.clientId())) return; + + if (dto.imageUrl() != null) { + // Bild-meddelande + addMessageSafely(new NtfyMessageDto( + dto.id(), + dto.time(), + dto.event(), + dto.topic(), + null, + null, + dto.imageUrl(), + dto.clientId() + )); + } else { + // Text-meddelande + addMessageSafely(dto); + } + }); + } + + /** + * Lägger till meddelande i ObservableList på korrekt tråd. + * Headless: läggs direkt, annars via Platform.runLater om ej på FX-tråden. + */ + private void addMessageSafely(NtfyMessageDto msg) { + if (headless) { + messages.add(msg); + } else if (Platform.isFxApplicationThread()) { + messages.add(msg); + } else { + Platform.runLater(() -> messages.add(msg)); + } + } + + /** + * Laddar upp en fil till lokal server och returnerar URL som sträng. + */ + protected String uploadToLocalServer(File imageFile) throws IOException { + URL url = new URL("http://localhost:8081/upload"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5_000); + conn.setReadTimeout(5_000); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + + // Bestäm MIME-typ + String contentType = Files.probeContentType(imageFile.toPath()); + if (contentType == null || (!contentType.startsWith("image/"))) { + throw new IOException("Unsupported image type: " + contentType); + } + conn.setRequestProperty("Content-Type", contentType); + + try (OutputStream os = conn.getOutputStream()) { + Files.copy(imageFile.toPath(), os); + } + + int status = conn.getResponseCode(); + if (status < 200 || status >= 300) { + conn.disconnect(); + throw new IOException("Upload failed with HTTP status: " + status); + } + + try (InputStream in = conn.getInputStream()) { + return new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } finally { + conn.disconnect(); + } + } + } diff --git a/src/main/java/com/example/ImageServer.java b/src/main/java/com/example/ImageServer.java new file mode 100644 index 00000000..dff51d56 --- /dev/null +++ b/src/main/java/com/example/ImageServer.java @@ -0,0 +1,101 @@ +package com.example; + +import com.sun.net.httpserver.HttpServer; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.file.*; +import java.util.UUID; + +public class ImageServer { + + private final int port; + private final String baseUrl; + private final Path imageDir; + private HttpServer server; + + public ImageServer(int port, Path imageDir) throws IOException { + this.port = port; + this.imageDir = imageDir; + if (!Files.exists(imageDir)) Files.createDirectories(imageDir); + + this.baseUrl = "http://localhost:" + port; + + startServer(); + } + + private void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(port), 0); + + // Upload endpoint + server.createContext("/upload", exchange -> { + try { + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); // Method Not Allowed + return; + } + + // Läs Content-Type från header + String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); + String extension = getExtensionForType(contentType); + + if (extension == null) { + exchange.sendResponseHeaders(415, -1); // Unsupported Media Type + return; + } + + byte[] content = exchange.getRequestBody().readAllBytes(); + String filename = UUID.randomUUID() + extension; + Path filePath = imageDir.resolve(filename); + + Files.write(filePath, content, StandardOpenOption.CREATE); + + // Returnera URL + String imageUrl = baseUrl + "/images/" + filename; + byte[] response = imageUrl.getBytes(); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + } finally { + exchange.close(); + } + }); + + // Serve images + server.createContext("/images", exchange -> { + try { + Path filePath = imageDir.resolve(exchange.getRequestURI().getPath().replace("/images/", "")).normalize(); + if (Files.exists(filePath)) { + exchange.sendResponseHeaders(200, Files.size(filePath)); + try (OutputStream os = exchange.getResponseBody()) { + Files.copy(filePath, os); + } + } else { + exchange.sendResponseHeaders(404, -1); + } + } finally { + exchange.close(); + } + }); + + server.setExecutor(null); + server.start(); + System.out.println("🖼️ Image server running at " + baseUrl); + } + + public void stop() { + if (server != null) server.stop(0); + } + + private String getExtensionForType(String contentType) { + if (contentType == null) return null; + return switch (contentType) { + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/gif" -> ".gif"; + default -> null; + }; + } +} diff --git a/src/main/java/com/example/NtfyConnection.java b/src/main/java/com/example/NtfyConnection.java new file mode 100644 index 00000000..04e164aa --- /dev/null +++ b/src/main/java/com/example/NtfyConnection.java @@ -0,0 +1,39 @@ +package com.example; + +import java.io.File; +import java.util.function.Consumer; + +public interface NtfyConnection { + + /** + * Sends a message to ntfy. The message can be plain text or a JSON/Markdown string containing links or formatting. + * + * @param jsonMessage the message to send + */ + void send(String jsonMessage); + + /** + * Uploads an image and notifies the server. + * + * @param imageFile the image file to send + * @param clientId the identifier of the sending client + * @return true if the image was successfully uploaded and the notification sent, false otherwise (e.g., network error) + */ + boolean sendImage(File imageFile, String clientId); + + /** + * Registers a consumer to receive incoming messages from ntfy. + * The consumer is called for each incoming message. + * + * @param consumer the handler for incoming messages + * @implNote Only one consumer should be registered per connection. Calling receive multiple times may overwrite the previous consumer. + * @implNote Messages from the client itself (matching the same clientId) may be ignored depending on the implementation. + */ + void receive(Consumer consumer); + + /** + * Stops receiving messages. After calling this method, the consumer passed to {@link #receive(Consumer)} may no longer be called. + * Safe to call multiple times. + */ + void stopReceiving(); +} diff --git a/src/main/java/com/example/NtfyConnectionImpl.java b/src/main/java/com/example/NtfyConnectionImpl.java new file mode 100644 index 00000000..0fcedaa6 --- /dev/null +++ b/src/main/java/com/example/NtfyConnectionImpl.java @@ -0,0 +1,93 @@ +package com.example; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Objects; +import java.util.function.Consumer; + +public class NtfyConnectionImpl implements NtfyConnection { + + private final HttpClient client = HttpClient.newHttpClient(); + private final String hostName; + private final ObjectMapper mapper = new ObjectMapper(); + private volatile boolean running = true; + private Thread receiverThread; + + public NtfyConnectionImpl(String hostName) { + if (hostName == null || hostName.isBlank()) { + throw new IllegalStateException("HOST_NAME is not configured for NtfyConnectionImpl"); + } + this.hostName = hostName; + } + + @Override + public void send(String message) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hostName + "/" + HelloModel.DEFAULT_TOPIC)) + .POST(HttpRequest.BodyPublishers.ofString(message)) + .header("Content-Type", "text/plain; charset=utf-8") + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + System.out.println("Sent message, status: " + response.statusCode()); + } catch (IOException | InterruptedException e) { + e.printStackTrace(); + } + } + + @Override + public boolean sendImage(java.io.File imageFile, String clientId) { + throw new UnsupportedOperationException("sendImage is handled in HelloModel now."); + } + + @Override + public void receive(Consumer messageHandler) { + running = true; + receiverThread = new Thread(() -> { + while (running) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hostName + "/" + HelloModel.DEFAULT_TOPIC + "/json")) + .GET() + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + String body = response.body(); + for (String line : body.split("\\R")) { + line = line.trim(); + if (line.isEmpty()) continue; + if (line.startsWith("data:")) line = line.substring(5).trim(); + + try { + NtfyMessageDto msg = mapper.readValue(line, NtfyMessageDto.class); + if ("message".equals(msg.event())) { + messageHandler.accept(msg); + } + } catch (Exception ignored) {} + } + + Thread.sleep(1000); + } catch (IOException | InterruptedException e) { + if (!running) break; + try { Thread.sleep(2000); } catch (InterruptedException ex) { break; } + } + } + }, "NtfyReceiverThread"); + receiverThread.setDaemon(true); + receiverThread.start(); + } + + @Override + public void stopReceiving() { + running = false; + if (receiverThread != null && receiverThread.isAlive()) { + receiverThread.interrupt(); + } + } +} diff --git a/src/main/java/com/example/NtfyMessageDto.java b/src/main/java/com/example/NtfyMessageDto.java new file mode 100644 index 00000000..abf86f42 --- /dev/null +++ b/src/main/java/com/example/NtfyMessageDto.java @@ -0,0 +1,24 @@ +package com.example; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record NtfyMessageDto( + String id, + long time, + String event, + String topic, + String message, + Attachment attachment, + String imageUrl, + String clientId +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public static record Attachment( + String url, + String name, + String type, + long size + ) {} +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 71574a27..81d64672 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -1,7 +1,13 @@ -module hellofx { +module HelloFX { requires javafx.controls; requires javafx.fxml; + requires io.github.cdimascio.dotenv.java; + requires java.net.http; + requires com.fasterxml.jackson.annotation; + requires com.fasterxml.jackson.databind; + requires jdk.httpserver; + + opens com.example to javafx.fxml, com.fasterxml.jackson.databind; - opens com.example to javafx.fxml; exports com.example; -} \ No newline at end of file +} diff --git a/src/main/resources/com/example/chat-style.css b/src/main/resources/com/example/chat-style.css new file mode 100644 index 00000000..3e8083f7 --- /dev/null +++ b/src/main/resources/com/example/chat-style.css @@ -0,0 +1,64 @@ +.root { + -fx-background-color: #F0F0F0; + -fx-font-family: "Segoe UI"; +} + +.sent-message { + -fx-background-color: #AEE6AA; + -fx-text-fill: #000000; + -fx-background-radius: 16 16 0 16; + -fx-font-size: 14px; + -fx-padding: 10px; + -fx-border-color: #98D59B; + -fx-border-width: 1px; + -fx-border-radius: 16 16 0 16; +} + +.received-message { + -fx-background-color: #FFFFFF; + -fx-text-fill: #000000; + -fx-background-radius: 16 16 16 0; + -fx-font-size: 14px; + -fx-padding: 10px; + -fx-border-color: #E0E0E0; + -fx-border-width: 1px; + -fx-border-radius: 16 16 16 0; +} + +.text-field { + -fx-background-radius: 10px; + -fx-padding: 5px; +} + +.button { + -fx-background-radius: 10px; + -fx-cursor: hand; +} + + +.image-view { + -fx-effect: dropshadow(gaussian, rgba(0,0,0,0.2), 10, 0, 0, 2); + -fx-background-radius: 10px; + -fx-border-radius: 10px; + -fx-padding: 5px; + -fx-cursor: hand; +} + +.scroll-pane { + -fx-background-color: transparent; + -fx-padding: 5px; +} + +.hbox { + -fx-spacing: 5px; +} + +.vbox { + -fx-spacing: 3px; +} + +.timestamp { + -fx-font-size: 10px; + -fx-text-fill: #888888; + -fx-padding: 0 5 0 5; +} diff --git a/src/main/resources/com/example/hello-view.fxml b/src/main/resources/com/example/hello-view.fxml index 20a7dc82..ada4c1e2 100644 --- a/src/main/resources/com/example/hello-view.fxml +++ b/src/main/resources/com/example/hello-view.fxml @@ -1,9 +1,31 @@ - + + + + + + - - - - + + + +
+ + + +
+ + + + + +