1
0

Basic implementation for notes app

This commit is contained in:
2022-08-25 17:14:37 +02:00
parent a0239ecda6
commit 2ab4497bd1
22 changed files with 671 additions and 171 deletions

View File

@@ -1,7 +1,3 @@
#content-container {
padding-top: 2em;
}
#widgets-container { #widgets-container {
padding: 2em; padding: 2em;
display: flex; display: flex;
@@ -16,7 +12,7 @@
.widget { .widget {
flex-grow: 1; flex-grow: 1;
background-color: #1d2121; background-color:var(--background-color-highlight);
padding: 1em; padding: 1em;
align-self: stretch; align-self: stretch;
} }

View File

@@ -4,6 +4,7 @@ import de.nbscloud.webcontainer.registry.App;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -12,22 +13,80 @@ public interface FilesService {
LocalDateTime lastModified) { LocalDateTime lastModified) {
} }
public void createAppDirectory(App app); class ContentTree {
private String name;
private boolean directory;
private String path;
private List<ContentTree> subTree = new ArrayList<>();
private ContentTree parent;
public void createDirectory(App app, Path path); public String getName() {
return name;
}
public void createFile(App app, Path path, byte[] content); public void setName(String name) {
this.name = name;
}
public void delete(App app, Path path); public boolean isDirectory() {
return directory;
}
public void get(App app, Path path); public void setDirectory(boolean directory) {
this.directory = directory;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public List<ContentTree> getSubTree() {
return subTree;
}
public void setSubTree(List<ContentTree> subTree) {
this.subTree = subTree;
}
public boolean getHasSubTree() {
return !this.subTree.isEmpty();
}
public ContentTree getParent() {
return parent;
}
public void setParent(ContentTree parent) {
this.parent = parent;
}
}
void createAppDirectory(App app);
void createDirectory(App app, Path path);
void createFile(App app, Path path, byte[] content);
void overwriteFile(App app, Path path, byte[] content);
void delete(App app, Path path);
byte[] get(App app, Path path);
/** /**
* Paths in return list are always relative to the appDir. * Paths in return list are always relative to the appDir. Non-recursive list.
* *
* @param app to list the files for * @param app to list the files for
* @param path in case of {@link Optional#EMPTY} the appDir is used as start dir. If not empty, path has to be relative * @param path in case of {@link Optional#EMPTY} the appDir is used as start dir. If not empty, path has to be relative
* to the appDir * to the appDir
*/ */
public List<ContentContainer> list(App app, Optional<Path> path); List<ContentContainer> list(App app, Optional<Path> path);
ContentTree getTree(App app, Optional<Path> path);
List<String> collectDirs(App app, Optional<Path> path);
} }

View File

@@ -1,6 +1,7 @@
package de.nbscloud.files; package de.nbscloud.files;
import de.nbscloud.files.api.FilesService.ContentContainer; import de.nbscloud.files.api.FilesService.ContentContainer;
import de.nbscloud.files.api.FilesService.ContentTree;
import de.nbscloud.files.config.FilesConfig; import de.nbscloud.files.config.FilesConfig;
import de.nbscloud.files.exception.FileSystemServiceException; import de.nbscloud.files.exception.FileSystemServiceException;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
@@ -21,6 +22,7 @@ import java.time.ZoneId;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
@@ -117,6 +119,14 @@ public class FileSystemService {
} }
} }
public void overwriteFile(Path path, byte[] content) {
try {
Files.write(path, content, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new FileSystemServiceException("Could not overwrite file", e);
}
}
public Path move(Path originalPath, Path newPath) { public Path move(Path originalPath, Path newPath) {
try { try {
return Files.move(originalPath, newPath, StandardCopyOption.ATOMIC_MOVE); return Files.move(originalPath, newPath, StandardCopyOption.ATOMIC_MOVE);
@@ -286,7 +296,7 @@ public class FileSystemService {
List<ContentContainer> list(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) { List<ContentContainer> list(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) {
try { try {
List<ContentContainer> contentList = Files.list(this.locationTracker.getCurrentLocation()) List<ContentContainer> contentList = Files.list(startPath)
.filter(path -> { .filter(path -> {
boolean ok = Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isDirectory(path); boolean ok = Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isDirectory(path);
@@ -319,22 +329,86 @@ public class FileSystemService {
} }
} }
ContentTree getTree(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) {
try {
if (!Files.isDirectory(startPath)) {
return null;
}
final ContentTree root = new ContentTree();
root.setName(startPath.getFileName().toString());
root.setPath(relativizer.apply(startPath).toString());
root.setDirectory(true);
final AtomicReference<ContentTree> current = new AtomicReference<>(root);
Files.walkFileTree(startPath, new SimpleFileVisitor<Path>() {
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
if (startPath.equals(dir)) {
return FileVisitResult.CONTINUE;
}
final ContentTree dirTree = new ContentTree();
dirTree.setName(dir.getFileName().toString());
dirTree.setPath(relativizer.apply(dir).toString());
dirTree.setDirectory(true);
dirTree.setParent(current.get());
current.get().getSubTree().add(dirTree);
current.set(dirTree);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
final ContentTree fileTree = new ContentTree();
fileTree.setName(file.getFileName().toString());
fileTree.setPath(relativizer.apply(file).toString());
fileTree.setDirectory(false);
fileTree.setParent(current.get());
current.get().getSubTree().add(fileTree);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
current.set(current.get().getParent());
return FileVisitResult.CONTINUE;
}
});
return root;
} catch (IOException e) {
throw new FileSystemServiceException("Could not list files", e);
}
}
public List<ContentContainer> list(SortOrder sortOrder) { public List<ContentContainer> list(SortOrder sortOrder) {
return list(this.locationTracker.getCurrentLocation(), sortOrder, path -> this.locationTracker.getRelativeToBaseDir(path)); return list(this.locationTracker.getCurrentLocation(), sortOrder, path -> this.locationTracker.getRelativeToBaseDir(path));
} }
public List<String> collectDirs(String sourceFile) { public List<String> collectDirs(String sourceFile) {
return collectDirs(this.locationTracker.resolve(sourceFile), this.locationTracker.getBaseDirPath(), path -> this.locationTracker.getRelativeToBaseDir(path), false);
}
List<String> collectDirs(Path sourcePath, Path baseDir, Function<Path, Path> relativizer, boolean includeSource) {
try { try {
final List<String> resultList = new ArrayList<>(); final List<String> resultList = new ArrayList<>();
final Path sourcePath = this.locationTracker.resolve(sourceFile);
final boolean sourceIsDir = Files.isDirectory(sourcePath); final boolean sourceIsDir = Files.isDirectory(sourcePath);
Files.walkFileTree(this.locationTracker.getBaseDirPath(), new SimpleFileVisitor<Path>() { Files.walkFileTree(baseDir, new SimpleFileVisitor<Path>() {
@Override @Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
super.visitFile(dir, attrs); super.visitFile(dir, attrs);
if (sourceIsDir && sourcePath.equals(dir)) { if (!includeSource && sourceIsDir && sourcePath.equals(dir)) {
return FileVisitResult.SKIP_SUBTREE; return FileVisitResult.SKIP_SUBTREE;
} }
@@ -350,7 +424,7 @@ public class FileSystemService {
return FileVisitResult.SKIP_SUBTREE; return FileVisitResult.SKIP_SUBTREE;
} }
final String relPath = locationTracker.getRelativeToBaseDir(dir).toString(); final String relPath = relativizer.apply(dir).toString();
resultList.add("/" + relPath); resultList.add("/" + relPath);

View File

@@ -18,6 +18,10 @@ public class FilesServiceImpl implements FilesService {
private AppLocationTracker locationTracker; private AppLocationTracker locationTracker;
private Path resolve(App app, Path path) { private Path resolve(App app, Path path) {
if(path.startsWith("/")) {
path = path.subpath(0, path.getNameCount());
}
return this.locationTracker.resolve(app.getId(), path); return this.locationTracker.resolve(app.getId(), path);
} }
@@ -41,14 +45,19 @@ public class FilesServiceImpl implements FilesService {
this.fileSystemService.createFile(resolve(app, path), content); this.fileSystemService.createFile(resolve(app, path), content);
} }
@Override
public void overwriteFile(App app, Path path, byte[] content) {
this.fileSystemService.overwriteFile(resolve(app, path), content);
}
@Override @Override
public void delete(App app, Path path) { public void delete(App app, Path path) {
this.fileSystemService.delete(resolve(app, path)); this.fileSystemService.delete(resolve(app, path));
} }
@Override @Override
public void get(App app, Path path) { public byte[] get(App app, Path path) {
this.fileSystemService.get(resolve(app, path)); return this.fileSystemService.get(resolve(app, path));
} }
@Override @Override
@@ -58,4 +67,21 @@ public class FilesServiceImpl implements FilesService {
return this.fileSystemService.list(p, FileSystemService.SortOrder.NATURAL, callbackPath -> appPath.relativize(callbackPath)); return this.fileSystemService.list(p, FileSystemService.SortOrder.NATURAL, callbackPath -> appPath.relativize(callbackPath));
} }
@Override
public ContentTree getTree(App app, Optional<Path> path) {
final Path appPath = resolve(app, Path.of(""));
final Path p = path.map(tmpPath -> resolve(app, tmpPath)).orElse(appPath);
return this.fileSystemService.getTree(p, FileSystemService.SortOrder.NATURAL, callbackPath -> appPath.relativize(callbackPath));
}
@Override
public List<String> collectDirs(App app, Optional<Path> path) {
final Path appPath = resolve(app, Path.of(""));
final Path p = path.map(tmpPath -> resolve(app, tmpPath)).orElse(appPath);
return this.fileSystemService.collectDirs(p, appPath, callbackPath -> appPath.relativize(callbackPath), true);
}
} }

View File

@@ -10,6 +10,7 @@ import de.nbscloud.files.config.FilesConfig;
import de.nbscloud.files.exception.FileSystemServiceException; import de.nbscloud.files.exception.FileSystemServiceException;
import de.nbscloud.files.form.RenameForm; import de.nbscloud.files.form.RenameForm;
import de.nbscloud.files.form.ShareForm; import de.nbscloud.files.form.ShareForm;
import de.nbscloud.webcontainer.MessageHelper;
import de.nbscloud.webcontainer.registry.AppRegistry; import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig; import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import org.apache.commons.io.input.ObservableInputStream; import org.apache.commons.io.input.ObservableInputStream;
@@ -64,12 +65,8 @@ public class FilesController implements InitializingBean {
private AppRegistry appRegistry; private AppRegistry appRegistry;
@Autowired @Autowired
private ObjectMapper mapper; private ObjectMapper mapper;
@Autowired
// We have to temporarily store messages as we redirect: in the manipulation methods private MessageHelper messageHelper;
// so everything we add to the model will be gone
private final List<String> errors = new ArrayList<>();
private final List<String> shareInfo = new ArrayList<>();
private final List<String> infoMessages = new ArrayList<>();
@GetMapping("/files/browse/**") @GetMapping("/files/browse/**")
public String start(Model model, HttpServletRequest httpServletRequest, String sortOrder) { public String start(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
@@ -77,9 +74,7 @@ public class FilesController implements InitializingBean {
updateLocation(httpServletRequest); updateLocation(httpServletRequest);
model.addAttribute("errors", getAndClear(this.errors)); this.messageHelper.addAndClearAll(model);
model.addAttribute("infoMessages", getAndClear(this.infoMessages));
model.addAttribute("shareInfo", getAndClear(this.shareInfo));
model.addAttribute("currentLocation", getCurrentLocationPrefixed()); model.addAttribute("currentLocation", getCurrentLocationPrefixed());
model.addAttribute("content", getContent(order)); model.addAttribute("content", getContent(order));
model.addAttribute("sortOrder", order); model.addAttribute("sortOrder", order);
@@ -98,17 +93,17 @@ public class FilesController implements InitializingBean {
this.locationTracker.resolve(filename), this.locationTracker.resolve(filename),
this.locationTracker.resolveTrash(filename)); this.locationTracker.resolveTrash(filename));
this.infoMessages.add("nbscloud.files.delete.success"); this.messageHelper.addInfo("nbscloud.files.delete.success");
} catch (RuntimeException e) { } catch (RuntimeException e) {
logger.error("Could not soft delete file", e); logger.error("Could not soft delete file", e);
this.errors.add(e.getMessage()); this.messageHelper.addError(e.getMessage());
} }
} else { } else {
// Hard delete // Hard delete
this.fileSystemService.delete(filename); this.fileSystemService.delete(filename);
this.infoMessages.add("nbscloud.files.delete.success"); this.messageHelper.addInfo("nbscloud.files.delete.success");
} }
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
@@ -138,11 +133,11 @@ public class FilesController implements InitializingBean {
try { try {
this.fileSystemService.move(sourcePath, targetPath); this.fileSystemService.move(sourcePath, targetPath);
this.infoMessages.add("nbscloud.files.rename.success"); this.messageHelper.addInfo("nbscloud.files.rename.success");
} catch (RuntimeException e) { } catch (RuntimeException e) {
logger.error("Could not rename file", e); logger.error("Could not rename file", e);
this.errors.add(e.getMessage()); this.messageHelper.addError(e.getMessage());
} }
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
@@ -194,11 +189,11 @@ public class FilesController implements InitializingBean {
try { try {
this.fileSystemService.createFile(file.getOriginalFilename(), file.getBytes()); this.fileSystemService.createFile(file.getOriginalFilename(), file.getBytes());
this.infoMessages.add("nbscloud.files.created.file.success"); this.messageHelper.addInfo("nbscloud.files.created.file.success");
} catch (IOException | RuntimeException e) { } catch (IOException | RuntimeException e) {
logger.error("Could not upload file", e); logger.error("Could not upload file", e);
this.errors.add(e.getMessage()); this.messageHelper.addError(e.getMessage());
} }
} }
@@ -210,11 +205,11 @@ public class FilesController implements InitializingBean {
try { try {
this.fileSystemService.createDirectory(dirName); this.fileSystemService.createDirectory(dirName);
this.infoMessages.add("nbscloud.files.created.dir.success"); this.messageHelper.addInfo("nbscloud.files.created.dir.success");
} catch (RuntimeException e) { } catch (RuntimeException e) {
logger.error("Could not create dir", e); logger.error("Could not create dir", e);
this.errors.add(e.getMessage()); this.messageHelper.addError(e.getMessage());
} }
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
@@ -241,6 +236,7 @@ public class FilesController implements InitializingBean {
final Share share = new Share(); final Share share = new Share();
// The format is always "yyyy-MM-dd", see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date // The format is always "yyyy-MM-dd", see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date
final LocalDate expiryDate = Optional.ofNullable(expiryDateString) final LocalDate expiryDate = Optional.ofNullable(expiryDateString)
.map(ed -> ed.isBlank() ? null : ed)
.map(ed -> LocalDate.parse(ed, DateTimeFormatter.ofPattern("yyyy-MM-dd"))) .map(ed -> LocalDate.parse(ed, DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.orElse(null); .orElse(null);
@@ -254,11 +250,11 @@ public class FilesController implements InitializingBean {
this.fileSystemService.createFile(this.locationTracker.resolveShare(share.getUuid()), shareJson.getBytes(StandardCharsets.UTF_8)); this.fileSystemService.createFile(this.locationTracker.resolveShare(share.getUuid()), shareJson.getBytes(StandardCharsets.UTF_8));
this.shareInfo.add("/files/shares?shareUuid=" + share.getUuid()); this.messageHelper.addShare("/files/shares?shareUuid=" + share.getUuid());
} catch (RuntimeException | JsonProcessingException e) { } catch (RuntimeException | JsonProcessingException e) {
logger.error("Could not share file", e); logger.error("Could not share file", e);
this.errors.add(e.getMessage()); this.messageHelper.addError(e.getMessage());
} }
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
@@ -325,20 +321,12 @@ public class FilesController implements InitializingBean {
} catch (FileSystemServiceException e) { } catch (FileSystemServiceException e) {
logger.error("Could not generate gallery", e); logger.error("Could not generate gallery", e);
this.errors.add(e.getMessage()); this.messageHelper.addError(e.getMessage());
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation(); return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
} }
} }
private List<String> getAndClear(List<String> source) {
final List<String> retList = new ArrayList<>(source);
source.clear();
return retList;
}
private List<ContentContainer> getContent(FileSystemService.SortOrder order) { private List<ContentContainer> getContent(FileSystemService.SortOrder order) {
final List<ContentContainer> contentList = this.fileSystemService.list(order); final List<ContentContainer> contentList = this.fileSystemService.list(order);

View File

@@ -73,105 +73,6 @@
width: 70em; width: 70em;
} }
#menu-container {
padding-top: 2em;
margin-left: 3em;
}
.files-menu-icon {
background: none !important;
border: none;
padding: 0 !important;
color: var(--text-color);
cursor: pointer;
font-size: 2em;
}
.files-menu-icon:hover {
color: var(--link-color);
}
#content-container {
padding-top: unset !important;
}
#create-dir-container > details > summary {
list-style-type: none;
}
#create-dir-container > details[open] > summary {
list-style-type: none;
}
#menu-container > div {
display: inline-block !important;
}
.create-dir-container-details-modal-content {
padding: 0.5em;
pointer-events: all;
display: inline;
}
.create-dir-container-details-modal {
background: var(--background-color);
filter: brightness(var(--background-brightness));
border-radius: 0.3em;
left: auto;
top: auto;
right: auto;
bottom: auto;
padding: 0;
pointer-events: none;
position: absolute;
display: flex;
flex-direction: column;
z-index: 100;
}
.menu-spacer {
width: 2em;
}
.messageContainer {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
}
.message {
margin-top: 2em;
border: 1px;
border-style: solid;
border-radius: 0.3em;
padding: 1em;
display: inline-block;
}
.shareMessage {
border-color: var(--good-color) !important;
}
.infoMessage {
border-color: var(--good-color) !important;
}
.errorMessage {
border-color: var(--error-color) !important;
}
@media only screen and (max-width: 450px) {
#menu-container {
padding-top: 1em;
margin-left: 1em;
}
.menu-spacer {
width: 1em;
}
}
#gallery-link { #gallery-link {
text-decoration: none; text-decoration: none;
} }

View File

@@ -13,22 +13,7 @@
<body> <body>
<div id="main-container"> <div id="main-container">
<div th:replace="includes/header :: header"/> <div th:replace="includes/header :: header"/>
<div class="messageContainer" th:each="shareInfo : ${shareInfo}"> <div th:replace="includes/messages :: messages"/>
<div th:if="${!shareInfo.isEmpty()}" class="shareMessage message">
<span th:text="#{'nbscloud.files.share-message'}"/>
<a th:href="@{${shareInfo}}" th:text="${shareInfo}"/>
</div>
</div>
<div class="messageContainer" th:each="error : ${errors}">
<div th:if="${!errors.isEmpty()}" class="errorMessage message">
<span th:text="${error}"/>
</div>
</div>
<div class="messageContainer" th:each="infoMessage : ${infoMessages}">
<div th:if="${!infoMessages.isEmpty()}" class="infoMessage message">
<span th:text="#{${infoMessage}}"/>
</div>
</div>
<div th:replace="files/includes/menu :: menu"/> <div th:replace="files/includes/menu :: menu"/>
<div id="content-container"> <div id="content-container">
<table id="files-content-table"> <table id="files-content-table">

View File

@@ -1,23 +1,23 @@
<div id="menu-container" th:fragment="menu"> <div id="menu-container" th:fragment="menu">
<div class="menu-spacer"></div> <div class="menu-spacer"></div>
<div id="upload-container"> <div id="upload-container" class="menu-container">
<form method="POST" action="#" th:action="@{/files/upload}" enctype="multipart/form-data"> <form method="POST" action="#" th:action="@{/files/upload}" enctype="multipart/form-data">
<!-- JavaScript required unfortunately --> <!-- JavaScript required unfortunately -->
<input id="upload-container-file-input" type="file" name="files" onchange="this.form.submit()" <input id="upload-container-file-input" type="file" name="files" onchange="this.form.submit()"
style="display: none;" multiple> style="display: none;" multiple>
<span id="upload-container-span-input" class="icon files-menu-icon" <span id="upload-container-span-input" class="icon menu-icon"
onclick="document.getElementById('upload-container-file-input').click();">&#xe9fc;</span> onclick="document.getElementById('upload-container-file-input').click();">&#xe9fc;</span>
</form> </form>
</div> </div>
<div class="menu-spacer"></div> <div class="menu-spacer"></div>
<div id="create-dir-container"> <div id="create-dir-container" class="menu-container">
<details> <details>
<summary> <summary>
<span id="create-dir-container-span-input" class="icon files-menu-icon">&#xe2cc;</span> <span id="create-dir-container-span-input" class="icon menu-icon">&#xe2cc;</span>
<div class="create-dir-container-details-modal-overlay"></div> <div class="create-dir-container-details-modal-overlay"></div>
</summary> </summary>
<div class="create-dir-container-details-modal"> <div class="menu-modal">
<div class="create-dir-container-details-modal-content"> <div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/files/createDir}" enctype="multipart/form-data"> <form method="POST" action="#" th:action="@{/files/createDir}" enctype="multipart/form-data">
<input id="create-dir-container-dir-name" type="text" name="dirName" /> <input id="create-dir-container-dir-name" type="text" name="dirName" />
<input type="submit" value="Create"/> <input type="submit" value="Create"/>
@@ -29,7 +29,7 @@
<div class="menu-spacer"></div> <div class="menu-spacer"></div>
<div> <div>
<a id="gallery-link" th:href="@{/files/gallery}"> <a id="gallery-link" th:href="@{/files/gallery}">
<span class="icon files-menu-icon">&#xe3b6;</span> <span class="icon menu-icon">&#xe3b6;</span>
</a> </a>
</div> </div>
</div> </div>

View File

@@ -21,6 +21,10 @@
<groupId>de.77zzcx7.nbs-cloud</groupId> <groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>web-container-config</artifactId> <artifactId>web-container-config</artifactId>
</dependency> </dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId> <artifactId>spring-boot-starter-thymeleaf</artifactId>

View File

@@ -0,0 +1,129 @@
package de.nbscloud.notes.controller;
import de.nbscloud.files.api.FilesService;
import de.nbscloud.notes.NotesApp;
import de.nbscloud.webcontainer.MessageHelper;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Optional;
@Controller
public class NotesController implements InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(NotesController.class);
@Autowired
private AppRegistry appRegistry;
@Autowired
private FilesService filesService;
@Autowired
private WebContainerSharedConfig webContainerSharedConfig;
@Autowired
private NotesApp app;
@Autowired
private MessageHelper messageHelper;
public static Optional<Path> notePathToPath(String notePath) {
final Optional<String> optNotePath = Optional.ofNullable(notePath);
return optNotePath.map(p -> Path.of(p));
}
@GetMapping("/notes")
public String start(Model model, @RequestParam(required = false) String notePath) {
final Optional<Path> optNotePath = notePathToPath(notePath);
if(optNotePath.isPresent()) {
final byte[] content = this.filesService.get(app, optNotePath.get());
model.addAttribute("content", new String(content));
}
this.messageHelper.addAndClearAll(model);
model.addAttribute("currentNote", notePath);
model.addAttribute("tree", this.filesService.getTree(app, Optional.empty()));
model.addAttribute("dirs", this.filesService.collectDirs(app, Optional.empty()));
model.addAttribute("apps", this.appRegistry.getAll());
this.webContainerSharedConfig.addDefaults(model);
return "notes/notesIndex";
}
@PostMapping("/notes/save")
public String save(String textContent, String notePath) {
final Optional<Path> optNotePath = notePathToPath(notePath);
if(optNotePath.isPresent()) {
this.filesService.overwriteFile(app, optNotePath.get(), textContent.getBytes(StandardCharsets.UTF_8));
this.messageHelper.addInfo("nbscloud.notes.save.success");
}
return "redirect:?notePath=" + notePath;
}
@PostMapping("/notes/delete")
public String delete(String notePath) {
final Optional<Path> optNotePath = notePathToPath(notePath);
if(optNotePath.isPresent()) {
this.filesService.delete(app, optNotePath.get());
this.messageHelper.addInfo("nbscloud.notes.delete.success");
}
return "redirect:";
}
@PostMapping("/notes/createDir")
public String createDirectory(String dirName, String parentPath, String currentPath) {
final Optional<Path> optParentPath = notePathToPath(parentPath);
if(optParentPath.isPresent()) {
final Path dirPath = optParentPath.get().resolve(dirName);
this.filesService.createDirectory(app, dirPath);
this.messageHelper.addInfo("nbscloud.notes.createDir.success");
}
if(currentPath == null || currentPath.isBlank()) {
return "redirect:";
}
return "redirect:?notePath=" + currentPath;
}
@PostMapping("/notes/createNote")
public String createNote(String noteName, String parentPath, String currentPath) {
final Optional<Path> optParentPath = notePathToPath(parentPath);
if(optParentPath.isPresent()) {
final Path notePath = optParentPath.get().resolve(noteName);
this.filesService.createFile(app, notePath, new byte[] {});
this.messageHelper.addInfo("nbscloud.notes.createNote.success");
return "redirect:?notePath=" + notePath;
}
return "redirect:?notePath=" + currentPath;
}
@Override
public void afterPropertiesSet() throws Exception {
this.filesService.createAppDirectory(this.app);
}
}

View File

@@ -0,0 +1,7 @@
nbscloud.notes.action.save=Save
nbscloud.notes.action.delete=Delete
nbscloud.notes.save.success=Saved
nbscloud.notes.delete.success=Deleted
nbscloud.notes.createDir.success=Directory created
nbscloud.notes.createNote.success=Note created

View File

@@ -0,0 +1,7 @@
nbscloud.notes.action.save=Speichern
nbscloud.notes.action.delete=L\u00F6schen
nbscloud.notes.save.success=Gespeichert
nbscloud.notes.delete.success=Gel\u00F6scht
nbscloud.notes.createDir.success=Verzeichnis erstellt
nbscloud.notes.createNote.success=Notiz erstellt

View File

@@ -0,0 +1,59 @@
#content-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
row-gap: 2em;
column-gap: 2em;
}
#nav-tree {
flex-grow: 1;
background-color: var(--background-color-highlight);
overflow-y: scroll;
}
#sub-content {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
row-gap: 2em;
column-gap: 2em;
flex-grow: 1;
flex-basis: 70%;
}
#nav-tree ul {
list-style-type: none;
padding-left: 1.6em;
}
#nav-tree p, a {
margin: 0em;
}
#edit-area {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
#edit-area textarea {
flex: 1;
background-color: var(--background-color-highlight);
color: var(--text-color);
resize: none;
}
#actions-sub {
display: flex;
flex-direction: row-reverse;
flex-wrap: nowrap;
row-gap: 2em;
column-gap: 2em;
margin-top: 2em;
}
#dir-container > * {
display: inline;
}

View File

@@ -0,0 +1,12 @@
<div th:fragment="tree-level">
<ul>
<li th:each="tmpSubTree : ${tree}">
<div th:if="${tmpSubTree.directory}" id="dir-container">
<span class="icon">&#xe2c7;</span>
<p th:text="${tmpSubTree.name}" />
</div>
<p th:if="${!tmpSubTree.directory}"><a th:href="@{/notes(notePath=${tmpSubTree.path})}" th:text="${tmpSubTree.name}" /></p>
<th:block th:include="@{notes/fragments/treeLevel} :: tree-level" th:with="tree=${tmpSubTree.subTree}" />
</li>
</ul>
</div>

View File

@@ -0,0 +1,47 @@
<div id="menu-container" th:fragment="menu">
<div class="menu-spacer"></div>
<div id="create-dir-container" class="menu-container">
<details>
<summary>
<span id="create-dir-container-span-input" class="icon menu-icon">&#xe2cc;</span>
</summary>
<div class="menu-modal">
<div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/notes/createDir}" enctype="multipart/form-data">
<input id="create-dir-container-dir-name" type="text" name="dirName" />
<select size="1" id="parentPath" name="parentPath">
<option th:each="dir : ${dirs}"
th:value="${dir}"
th:text="${dir}" />
</select>
<input type="hidden" name="notePath" th:value="${currentNote}" class="display-none" />
<input type="submit" value="Create"/>
</form>
</div>
</div>
</details>
</div>
<div class="menu-spacer"></div>
<div id="create-note-container" class="menu-container">
<details>
<summary>
<span id="create-note-container-span-input" class="icon menu-icon">&#xe89c;</span>
<div class="create-note-container-details-modal-overlay"></div>
</summary>
<div class="menu-modal">
<div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/notes/createNote}" enctype="multipart/form-data">
<input id="create-note-container-dir-name" type="text" name="noteName" />
<select size="1" id="parentPathNote" name="parentPath">
<option th:each="dir : ${dirs}"
th:value="${dir}"
th:text="${dir}" />
</select>
<input type="hidden" name="notePath" th:value="${currentNote}" class="display-none" />
<input type="submit" value="Create"/>
</form>
</div>
</div>
</details>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{nbscloud.notes.index.title} + ${currentNote}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
<link rel="stylesheet" th:href="@{/css/main.css}"/>
<link rel="stylesheet" th:href="@{/css/notes_main.css}"/>
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
</head>
<body>
<div id="main-container">
<div th:replace="includes/header :: header"/>
<div th:replace="includes/messages :: messages"/>
<div th:replace="notes/includes/menu :: menu"/>
<div id="content-container">
<div id="nav-tree">
<th:block th:include="@{notes/fragments/treeLevel} :: tree-level" th:with="tree=${tree}" />
</div>
<div id="sub-content">
<form method="POST" action="#" enctype="multipart/form-data">
<div id="edit-area">
<textarea type="text" th:if="${content != null}" th:text="${content}" rows="25" name="textContent"/>
</div>
<div id="actions">
<div th:if="${content != null}" id="actions-sub">
<input type="submit" th:value="#{nbscloud.notes.action.save}" th:formaction="@{/notes/save}" />
<input type="submit" th:value="#{nbscloud.notes.action.delete}" th:formaction="@{/notes/delete}" />
<input type="hidden" name="notePath" th:value="${currentNote}" class="display-none" />
</div>
</div>
</form>
</div>
</div>
<div th:replace="includes/footer :: footer"/>
</div>
</body>
</html>

View File

@@ -0,0 +1,43 @@
package de.nbscloud.webcontainer;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
import java.util.ArrayList;
import java.util.List;
@Component
public class MessageHelper {
// We have to temporarily store messages as we redirect: in some methods
// so everything we add to the model will be gone, that's why we store messages
// temporarily in here
private final List<String> errors = new ArrayList<>();
private final List<String> shareInfo = new ArrayList<>();
private final List<String> infoMessages = new ArrayList<>();
public void addAndClearAll(Model model) {
model.addAttribute("errors", getAndClear(this.errors));
model.addAttribute("infoMessages", getAndClear(this.infoMessages));
model.addAttribute("shareInfo", getAndClear(this.shareInfo));
}
private static List<String> getAndClear(List<String> source) {
final List<String> retList = new ArrayList<>(source);
source.clear();
return retList;
}
public void addError(String message) {
this.errors.add(message);
}
public void addShare(String message) {
this.shareInfo.add(message);
}
public void addInfo(String message) {
this.infoMessages.add(message);
}
}

View File

@@ -9,7 +9,7 @@ info.build.group=@project.groupId@
info.build.artifact=@project.artifactId@ info.build.artifact=@project.artifactId@
info.build.version=@project.version@ info.build.version=@project.version@
spring.messages.basename=i18n/container_messages,i18n/files_messages,i18n/dashboard_messages spring.messages.basename=i18n/container_messages,i18n/files_messages,i18n/dashboard_messages,i18n/notes_messages
spring.servlet.multipart.max-file-size=-1 spring.servlet.multipart.max-file-size=-1
spring.servlet.multipart.max-request-size=-1 spring.servlet.multipart.max-request-size=-1

View File

@@ -3,6 +3,7 @@
--error-color: #D30000; --error-color: #D30000;
--text-color: #7f7f7f; --text-color: #7f7f7f;
--background-color: #1d1f21; --background-color: #1d1f21;
--background-color-highlight: #1d2121;
--link-color: #87ab63; --link-color: #87ab63;
--hover-color: #1f1f2f; --hover-color: #1f1f2f;
--border-color: #7f7f7f; --border-color: #7f7f7f;

View File

@@ -3,6 +3,7 @@
--error-color: #D30000; --error-color: #D30000;
--text-color: #000000; --text-color: #000000;
--background-color: #FFFFFF; --background-color: #FFFFFF;
--background-color-highlight: darkgrey;
--link-color: #0000EE; --link-color: #0000EE;
--hover-color: lightgrey; --hover-color: lightgrey;
--border-color: #ddd; --border-color: #ddd;

View File

@@ -93,3 +93,106 @@ tr:hover {
padding-right: 1em; padding-right: 1em;
} }
} }
#menu-container {
padding-top: 2em;
margin-left: 3em;
}
.menu-icon {
background: none !important;
border: none;
padding: 0 !important;
color: var(--text-color);
cursor: pointer;
font-size: 2em;
}
.menu-container > details > summary {
list-style-type: none;
}
.menu-container > details[open] > summary {
list-style-type: none;
}
.menu-icon:hover {
color: var(--link-color);
}
.menu-spacer {
width: 2em;
}
@media only screen and (max-width: 450px) {
#menu-container {
padding-top: 1em;
margin-left: 1em;
}
.menu-spacer {
width: 1em;
}
}
#menu-container > div {
display: inline-block !important;
}
#content-container {
padding-top: 2em;
}
.display-none {
display: none;
}
.messageContainer {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
}
.message {
margin-top: 2em;
border: 1px;
border-style: solid;
border-radius: 0.3em;
padding: 1em;
display: inline-block;
}
.shareMessage {
border-color: var(--good-color) !important;
}
.infoMessage {
border-color: var(--good-color) !important;
}
.errorMessage {
border-color: var(--error-color) !important;
}
.menu-modal-content {
padding: 0.5em;
pointer-events: all;
display: inline;
}
.menu-modal {
background: var(--background-color);
filter: brightness(var(--background-brightness));
border-radius: 0.3em;
left: auto;
top: auto;
right: auto;
bottom: auto;
padding: 0;
pointer-events: none;
position: absolute;
display: flex;
flex-direction: column;
z-index: 100;
}

View File

@@ -0,0 +1,18 @@
<div id="messages-container" th:fragment="messages">
<div class="messageContainer" th:each="shareInfo : ${shareInfo}">
<div th:if="${!shareInfo.isEmpty()}" class="shareMessage message">
<span th:text="#{'nbscloud.files.share-message'}"/>
<a th:href="@{${shareInfo}}" th:text="${shareInfo}"/>
</div>
</div>
<div class="messageContainer" th:each="error : ${errors}">
<div th:if="${!errors.isEmpty()}" class="errorMessage message">
<span th:text="${error}"/>
</div>
</div>
<div class="messageContainer" th:each="infoMessage : ${infoMessages}">
<div th:if="${!infoMessages.isEmpty()}" class="infoMessage message">
<span th:text="#{${infoMessage}}"/>
</div>
</div>
</div>