working, deployed

This commit is contained in:
Patrick Haßel 2025-08-07 15:23:25 +02:00
commit 31f6295833
46 changed files with 16462 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

12
application.properties Normal file
View File

@ -0,0 +1,12 @@
logging.level.de.ph87=DEBUG
#-
spring.datasource.url=jdbc:h2:./database;AUTO_SERVER=TRUE
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
#-
spring.jpa.hibernate.ddl-auto=create
#-
spring.jackson.serialization.indent_output=true
#-
server.port=8084

41
deploy.sh Normal file
View File

@ -0,0 +1,41 @@
#!/bin/bash
cd "$(dirname "$0")" || exit 1
echo "+---------------------------------------------------+"
echo "| Building backend |"
echo "+---------------------------------------------------+"
mvn clean install || exit 1
echo "+---------------------------------------------------+"
echo "| Uploading backend |"
echo "+---------------------------------------------------+"
rsync --archive -e 'ssh -p 2222' ./target/IsabellTimo.jar mc@mc.ph87.de:/srv/IsabellTimo/backend/ || exit 1
echo "+---------------------------------------------------+"
echo "| Building frontend |"
echo "+---------------------------------------------------+"
cd src/main/angular || exit 1
npm run build || exit 1
echo "+---------------------------------------------------+"
echo "| Uploading frontend |"
echo "+---------------------------------------------------+"
rsync --archive --delete -e 'ssh -p 2222' ./dist/angular/browser/ mc@mc.ph87.de:/srv/IsabellTimo/frontend/ || exit 1
echo "+---------------------------------------------------+"
echo "| Restarting docker |"
echo "+---------------------------------------------------+"
ssh root@mc.ph87.de -p 2222 '/srv/IsabellTimo/restart.sh' || exit 1

63
pom.xml Normal file
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.ph87</groupId>
<artifactId>IsabellTimo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.6</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.mortennobel</groupId>
<artifactId>java-image-scaling</artifactId>
<version>0.8.6</version>
</dependency>
<dependency>
<groupId>com.drewnoakes</groupId>
<artifactId>metadata-extractor</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
</dependencies>
<build>
<finalName>IsabellTimo</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
src/main/angular/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@ -0,0 +1,59 @@
# IsabellTimo
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.12.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@ -0,0 +1,124 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"IsabellTimo": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "less",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/angular",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.less"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "IsabellTimo:build:production"
},
"development": {
"buildTarget": "IsabellTimo:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "less",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.less"
],
"scripts": []
}
}
}
}
}
}

14908
src/main/angular/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"name": "angular",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.12",
"@angular/cli": "^19.2.12",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="white">
<path d="M7.828 5l-1-1H22v15.172l-1-1v-.69l-3.116-3.117-.395.296-.714-.714.854-.64a.503.503 0 0 1 .657.046L21 16.067V5zM3 20v-.519l2.947-2.947a1.506 1.506 0 0 0 .677.163 1.403 1.403 0 0 0 .997-.415l2.916-2.916-.706-.707-2.916 2.916a.474.474 0 0 1-.678-.048.503.503 0 0 0-.704.007L3 18.067V5.828l-1-1V21h16.172l-1-1zM17 8.5A1.5 1.5 0 1 1 15.5 7 1.5 1.5 0 0 1 17 8.5zm-1 0a.5.5 0 1 0-.5.5.5.5 0 0 0 .5-.5zm5.646 13.854l.707-.707-20-20-.707.707z"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

View File

@ -0,0 +1,6 @@
<div id="title" class="coloredText">
<div>Isabell⚭Timo</div>
<div>09. August 2025</div>
</div>
<router-outlet/>

View File

@ -0,0 +1,6 @@
@import "../config";
#title {
font-size: 150%;
margin-bottom: @space;
}

View File

@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.less'
})
export class AppComponent {
title = 'IsabellTimo';
}

View File

@ -0,0 +1,13 @@
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
import {provideRouter} from '@angular/router';
import {routes} from './app.routes';
import {provideHttpClient} from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({eventCoalescing: true}),
provideRouter(routes),
provideHttpClient(),
]
};

View File

@ -0,0 +1,10 @@
import {Routes} from '@angular/router';
import {UploadComponent} from './upload/upload.component';
import {GalleryComponent} from './gallery/gallery.component';
import {PendingChangesGuard} from './upload/pending.guard';
export const routes: Routes = [
{path: 'gallery', component: GalleryComponent},
{path: '', component: UploadComponent, canDeactivate: [PendingChangesGuard]},
{path: '**', redirectTo: ''},
];

View File

@ -0,0 +1,32 @@
import {orNull, validateDate, validateString} from '../validators';
export class Picture {
constructor(
readonly uuid: string,
readonly originalDate: Date | null,
readonly uploadDate: Date,
) {
//
}
static fromJson(json: any): Picture {
return new Picture(
validateString(json.uuid),
orNull(json.originalDate, validateDate),
validateDate(json.uploadDate),
);
}
static byUploadDateDesc(a: Picture, b: Picture): number {
return b.uploadDate.getTime() - a.uploadDate.getTime();
}
static byOriginalDateDesc(a: Picture, b: Picture): number {
const aa = a.originalDate === null ? a.uploadDate.getTime() : a.originalDate.getTime();
const bb = b.originalDate === null ? b.uploadDate.getTime() : b.originalDate.getTime();
return bb - aa;
}
}

View File

@ -0,0 +1,20 @@
<div class="button buttonGoTo" routerLink="">
&larr; Foto hochladen
</div>
<div>
<span class="button" *ngIf="sortByUpload" (click)="sortByUpload = !sortByUpload">Zuletzt hochgeladen</span>
<span class="button" *ngIf="!sortByUpload" (click)="sortByUpload = !sortByUpload">Zuletzt erstellt</span>
</div>
<div id="images" *ngIf="pictures.length > 0">
<img [src]="pictureUrl(picture)" *ngFor="let picture of sorted()" alt="" loading="lazy">
</div>
<div class="noImages" *ngIf="pictures.length === 0">
Noch keine Fotos<br>hochgeladen
</div>
<div class="button buttonGoTo" routerLink="">
&larr; Foto hochladen
</div>

View File

@ -0,0 +1,12 @@
@import "../../config";
img {
max-width: 95vw;
margin-bottom: calc(@space / 2);
margin-left: calc(@space / 2);
margin-right: calc(@space / 2);
}
.noImages {
margin: calc(5 * @space) 0;
}

View File

@ -0,0 +1,41 @@
import {Component, OnInit} from '@angular/core';
import {Picture} from './Picture';
import {NgForOf, NgIf} from '@angular/common';
import {RouterLink} from '@angular/router';
import {PictureService} from './picture.service';
@Component({
selector: 'app-gallery',
imports: [
NgForOf,
RouterLink,
NgIf
],
templateUrl: './gallery.component.html',
styleUrl: './gallery.component.less'
})
export class GalleryComponent implements OnInit {
protected pictures: Picture[] = [];
protected sortByUpload: boolean = true;
constructor(
readonly pictureService: PictureService,
) {
//
}
ngOnInit(): void {
this.pictureService.all(list => this.pictures = list);
}
pictureUrl(picture: Picture): string {
return this.pictureService.apiUrl('http', ['Picture', picture.uuid, 'preview']);
}
sorted(): Picture[] {
return this.pictures.sort(this.sortByUpload ? Picture.byUploadDateDesc : Picture.byOriginalDateDesc);
}
}

View File

@ -0,0 +1,33 @@
import {Injectable} from "@angular/core";
import {HttpClient} from "@angular/common/http";
import {map} from "rxjs";
import {Picture} from "./Picture";
@Injectable({
providedIn: 'root'
})
export class PictureService {
readonly secure = location.protocol.endsWith('s:') ? 's' : '';
constructor(
readonly http: HttpClient,
) {
//
}
all(next: (list: Picture[]) => any): void {
this.http.get<any[]>(this.apiUrl("http", ['Picture', 'all'])).pipe(map(list => list.map(Picture.fromJson))).subscribe(next);
}
upload(file: File, success: () => any, error: () => any): void {
const form = new FormData();
form.append('file', file);
this.http.post(this.apiUrl("http", ['Picture', 'upload']), form).subscribe({next: success, error: error});
}
apiUrl(protocol: string, path: string[]): string {
return `${protocol}${this.secure}://${location.hostname}/api/${path.join('/')}`;
}
}

View File

@ -0,0 +1,21 @@
import {CanDeactivate, CanDeactivateFn} from '@angular/router';
import {Injectable} from '@angular/core';
export interface CanComponentDeactivate {
canDeactivate(): boolean;
}
export const pendingGuard: CanDeactivateFn<CanComponentDeactivate> = (component, currentRoute, currentState, nextState) => {
return component.canDeactivate();
};
@Injectable({providedIn: 'root'})
export class PendingChangesGuard implements CanDeactivate<CanComponentDeactivate> {
canDeactivate(component: CanComponentDeactivate): boolean | Promise<boolean> {
return component.canDeactivate();
}
}

View File

@ -0,0 +1,37 @@
<div class="button buttonGoTo" routerLink="/gallery">
Zur Galerie &rarr;
</div>
<div>
<label class="button" [class.hidden]="uploading">
<input #input type="file" name="file" accept="image/*" (change)="fileChanged()">
Foto auswählen
</label>
<button class="button buttonDisabled" [class.hidden]="!uploading">
Foto auswählen
</button>
<div id="previewBox">
<img #preview id="preview" alt="Vorschau des Bildes" src="placeholder.svg">
</div>
<button class="button" [class.buttonError]="success === false" [class.buttonDisabled]="!selected || uploading" (click)="upload()">
<ng-container *ngIf="!uploading">
<ng-container *ngIf="success === null">
Hochladen
</ng-container>
<ng-container *ngIf="success === true">
Erfolgreich hochgeladen 😊
</ng-container>
<ng-container *ngIf="success === false">
Fehler! Nochmal?
</ng-container>
</ng-container>
<ng-container *ngIf="uploading">
Lade hoch...
</ng-container>
</button>
</div>

View File

@ -0,0 +1,26 @@
@import "../../config";
#previewBox {
text-align: center;
height: 18em;
width: 90vw;
margin: 0 auto @space;
background-color: @previewBack;
border: 1px solid @previewBorder;
border-radius: 0.3em;
overflow: hidden;
padding: @space;
display: flex;
justify-content: center;
align-items: center;
}
#preview {
max-width: 100%;
max-height: 100%;
}
input[type=file] {
display: none !important;
}

View File

@ -0,0 +1,87 @@
import {Component, ElementRef, HostListener, OnDestroy, ViewChild} from '@angular/core';
import {RouterLink} from '@angular/router';
import {CanComponentDeactivate} from './pending.guard';
import {NgIf} from '@angular/common';
import {PictureService} from '../gallery/picture.service';
@Component({
selector: 'app-upload',
imports: [
RouterLink,
NgIf
],
templateUrl: './upload.component.html',
styleUrl: './upload.component.less'
})
export class UploadComponent implements OnDestroy, CanComponentDeactivate {
@ViewChild('preview')
protected preview!: ElementRef;
@ViewChild('input')
protected input!: ElementRef;
protected selected: File | null = null;
protected uploading: boolean = false;
protected success: boolean | null = null;
constructor(
protected imageService: PictureService,
) {
//
}
fileChanged() {
this.reset(null);
const file = this.input.nativeElement.files?.[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = readerEvent => {
this.preview.nativeElement.src = (readerEvent.target?.result || '') as string;
this.selected = file;
};
reader.readAsDataURL(file);
}
}
private reset(success: boolean | null) {
this.uploading = false;
this.success = success;
if (success !== false) {
this.selected = null;
this.preview.nativeElement.src = 'placeholder.svg';
}
}
@HostListener('window:beforeunload', ['$event'])
unloadNotification($event: BeforeUnloadEvent): void {
if (this.selected) {
$event.preventDefault();
}
}
canDeactivate(): boolean {
if (this.selected) {
return confirm('Du hast ein Foto ausgewählt, aber noch nicht hochgeladen. Wirklich verlassen?');
}
return true;
}
ngOnDestroy(): void {
window.removeEventListener('beforeunload', this.unloadNotification);
}
upload() {
if (this.uploading) {
return;
}
if (!this.selected) {
return;
}
this.uploading = true;
this.imageService.upload(this.selected, () => this.reset(true), () => this.reset(false));
}
}

View File

@ -0,0 +1,21 @@
export function validateString(value: any) {
if (!(typeof value === 'string')) {
throw new Error("Not a string: " + JSON.stringify(value));
}
return value;
}
export function validateDate(value: any): Date {
const date = new Date(Date.parse(validateString(value)));
if (!date) {
throw new Error("Not a date: " + JSON.stringify(value));
}
return date;
}
export function orNull<T, R>(t: T | null | undefined, map: (t: T) => R): R | null {
if (t === null || t === undefined) {
return null;
}
return map(t);
}

View File

@ -0,0 +1,7 @@
@space: 0.5em;
@magenta: #6d2f47;
@gold: #eee08c;
@previewBack: @magenta;
@previewBorder: gray;

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Isabell ⚭ Timo</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -0,0 +1,91 @@
@import "config";
body {
font-size: 3vh;
font-family: sans-serif;
text-align: center;
user-select: none;
margin: 0;
width: 100vw;
height: 100vh;
background: #ffc9a3 url("/background.jpg") fixed center top;
background-size: cover;
}
input, select, button {
all: unset;
font-size: inherit;
}
div {
box-sizing: border-box;
}
.coloredText {
color: white;
text-shadow: 0 0 0.5em @magenta, 0 0 0.5em @magenta, 0 0 0.5em @magenta;
}
.button {
display: inline-block;
padding: @space;
margin-bottom: @space;
cursor: pointer;
background: linear-gradient(145deg, #fff4c2 0%, #ffd700 40%, #e6ac00 100%);
color: #3b2900;
font-weight: bold;
border: none;
border-radius: 0.5em;
box-shadow: inset 0 0.1em 0 rgba(255, 255, 255, 0.6), 0 0.2em 0.4em rgba(0, 0, 0, 0.3);
transition: all 0.2s ease-in-out;
position: relative;
overflow: hidden;
}
.button:not(.buttonDisabled):hover {
filter: brightness(1.1);
box-shadow: inset 0 0.1em 0 rgba(255, 255, 255, 0.5), 0 0.25em 0.5em rgba(0, 0, 0, 0.4);
transform: translateY(-0.1em);
}
.button:not(.buttonDisabled):active {
transform: translateY(0.1em);
box-shadow: inset 0 0.2em 0.5em rgba(0, 0, 0, 0.4), 0 0.2em 0.2em rgba(0, 0, 0, 0.2);
}
.button:not(.buttonDisabled):before {
content: "";
position: absolute;
top: 0;
left: -75%;
width: 50%;
height: 100%;
background: linear-gradient(120deg,
rgba(255, 255, 255, 0.0) 0%,
rgba(255, 255, 255, 0.6) 50%,
rgba(255, 255, 255, 0.0) 100%);
transform: skewX(-25deg);
transition: all 0.3s ease;
pointer-events: none;
}
.button:not(.buttonDisabled):hover::before {
left: 125%;
transition: left 0.75s ease-out;
}
.buttonGoTo {
background-color: @magenta;
}
.buttonDisabled {
filter: grayscale(100%);
}
.buttonError {
background: red !important;
}
.hidden {
display: none;
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,13 @@
package de.ph87.isabell_und_timo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Backend {
public static void main(String[] args) {
SpringApplication.run(Backend.class, args);
}
}

View File

@ -0,0 +1,14 @@
package de.ph87.isabell_und_timo.picture;
import java.nio.file.Path;
import java.nio.file.Paths;
public class CONFIG {
public static final int PREVIEW_SIZE = 1000;
public static final Path RESIZED = Paths.get("images/%d".formatted(PREVIEW_SIZE));
public static final Path ORIGINAL = Paths.get("images/original");
}

View File

@ -0,0 +1,102 @@
package de.ph87.isabell_und_timo.picture;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Getter
@ToString
@NoArgsConstructor
public class Picture {
@Id
private String uuid = UUID.randomUUID().toString();
@Version
private long version;
@NonNull
@Column(nullable = false)
private String originalFilename;
@Column
@Nullable
private ZonedDateTime originalDate;
@Column
@Nullable
private long size;
@Column
@Nullable
private int height;
@Column
@Nullable
private int width;
@Column
@Nullable
private String make;
@Column
@Nullable
private String model;
@NonNull
private String uploadFilename;
@NonNull
@Column(nullable = false)
private ZonedDateTime uploadDate;
@NonNull
@Column(nullable = false)
private String uploadAddress;
@Column
@Nullable
private String uploadAgent;
@Setter
@Column(nullable = false)
private boolean visible = true;
public Picture(
@NonNull final String originalFilename,
@Nullable final ZonedDateTime originalDate,
final long size,
final int width,
final int height,
@Nullable final String make,
@Nullable final String model,
@NonNull final String uploadFilename,
@NonNull final ZonedDateTime uploadDate,
@NonNull final String uploadAddress,
@Nullable final String uploadAgent
) {
this.originalFilename = originalFilename;
this.originalDate = originalDate;
this.size = size;
this.width = width;
this.height = height;
this.make = make;
this.model = model;
this.uploadFilename = uploadFilename;
this.uploadDate = uploadDate;
this.uploadAddress = uploadAddress;
this.uploadAgent = uploadAgent;
}
}

View File

@ -0,0 +1,54 @@
package de.ph87.isabell_und_timo.picture;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.stream.Collectors;
@Slf4j
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("PictureAdmin")
public class PictureAdminController {
private final PictureService pictureService;
private final PictureRepository pictureRepository;
@NonNull
@GetMapping("visible")
public String visible(@NonNull final HttpServletRequest request) {
verifyLocal(request);
return pictureRepository
.findAllDtoByVisibleTrue()
.stream()
.map(p -> "<a href='/PictureAdmin/%s/visible/false'><img src='/Picture/%s/preview' loading='lazy' alt='%s' style='width: 100%%;'></a>\n".formatted(p.uuid, p.uuid, p.uuid))
.collect(Collectors.joining("\n"));
}
// @GetMapping("{uuid}/visible/{visible}")
// public void visible(@PathVariable @NonNull final String uuid, @PathVariable final boolean visible, @NonNull final HttpServletRequest request, @NonNull final HttpServletResponse response) throws IOException {
// verifyLocal(request);
// pictureService.visible(uuid, visible);
// response.sendRedirect(request.get);
// }
private static void verifyLocal(final HttpServletRequest request) {
if (!request.getRemoteAddr().startsWith("10.") && !request.getRemoteAddr().equals("127.0.0.1")) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
}

View File

@ -0,0 +1,204 @@
package de.ph87.isabell_und_timo.picture;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.mortennobel.imagescaling.DimensionConstrain;
import com.mortennobel.imagescaling.MultiStepRescaleOp;
import jakarta.annotation.Nullable;
import jakarta.servlet.MultipartConfigElement;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.util.unit.DataSize;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import javax.imageio.ImageIO;
import java.awt.geom.AffineTransform;
import java.awt.image.AffineTransformOp;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import static de.ph87.isabell_und_timo.picture.CONFIG.PREVIEW_SIZE;
@Slf4j
@CrossOrigin
@RestController
@RequiredArgsConstructor
@RequestMapping("Picture")
public class PictureController {
private final PictureService pictureService;
private final PictureRepository pictureRepository;
@Bean
public MultipartConfigElement multipartConfigElement() {
final MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setMaxFileSize(DataSize.ofMegabytes(15));
factory.setMaxRequestSize(DataSize.ofMegabytes(15));
return factory.createMultipartConfig();
}
@NonNull
@GetMapping("all")
public List<PictureDto> all() {
return pictureRepository.findAllDtoByVisibleTrue();
}
@GetMapping("{uuid}/preview")
public void preview(@PathVariable @NonNull final String uuid, @NonNull final HttpServletRequest request, @NonNull final HttpServletResponse response) throws IOException {
final PictureInternal pictureDto = pictureRepository.findDtoByUuid(uuid).orElseThrow(() -> {
log.warn("Tried accessing NON-EXISTENT picture: {}", request);
return new ResponseStatusException(HttpStatus.NOT_FOUND);
});
if (!pictureDto.visible) {
log.warn("Tried accessing INVISIBLE picture: {}", request);
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
try (final FileInputStream input = new FileInputStream(pictureDto.getPreviewPath().toFile())) {
response.getOutputStream().write(input.readAllBytes());
}
}
@PostMapping(value = "upload")
public void uploadImage(
@NonNull @RequestParam("file") final MultipartFile fileStream,
@NonNull final HttpServletRequest request,
@Nullable @RequestHeader(value = "User-Agent", required = false) final String userAgent
) throws IOException, ImageProcessingException {
if (fileStream.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Missing 'file'");
}
final String originalFilename = fileStream.getOriginalFilename() == null ? "" : fileStream.getOriginalFilename();
final int dotIndex = originalFilename.lastIndexOf('.');
final String extension = (dotIndex >= 0) ? originalFilename.substring(dotIndex + 1) : "jpg";
final String baseName = (dotIndex >= 0) ? originalFilename.substring(0, dotIndex) : originalFilename;
final ZonedDateTime uploadDate = ZonedDateTime.now();
final String filename = "%s_%s.%s".formatted(uploadDate.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), baseName, extension);
final ByteArrayInputStream buffer = new ByteArrayInputStream(fileStream.getBytes());
BufferedImage originalImage = ImageIO.read(buffer);
if (originalImage == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Not a valid image");
}
buffer.reset();
final Metadata metadata = ImageMetadataReader.readMetadata(buffer);
final ExifSubIFDDirectory exif = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
final ZonedDateTime date = exif != null && exif.getDateOriginal() != null ? ZonedDateTime.ofInstant(exif.getDateOriginal().toInstant(), ZoneId.systemDefault()) : null;
final ExifIFD0Directory ifd0 = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
final String make = ifd0 != null ? ifd0.getString(ExifIFD0Directory.TAG_MAKE) : null;
final String model = ifd0 != null ? ifd0.getString(ExifIFD0Directory.TAG_MODEL) : null;
try {
if (ifd0 != null && ifd0.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
final int orientation = ifd0.getInt(ExifIFD0Directory.TAG_ORIENTATION);
originalImage = transformImageByOrientation(originalImage, orientation);
}
} catch (MetadataException e) {
log.error("Failed to read orientation: filename={}, error={}", filename, e.getMessage());
}
final PictureInternal pictureDto = pictureService.create(date, make, model, originalImage.getWidth(), originalImage.getHeight(), fileStream.getSize(), originalFilename, filename, uploadDate, request.getRemoteAddr(), userAgent);
final Path originalPath = pictureDto.getOriginalPath();
final Path previewPath = pictureDto.getPreviewPath();
mkdir(originalPath.getParent());
mkdir(previewPath.getParent());
buffer.reset();
Files.copy(buffer, originalPath);
final MultiStepRescaleOp rescale = new MultiStepRescaleOp(DimensionConstrain.createMaxDimension(PREVIEW_SIZE, PREVIEW_SIZE));
final BufferedImage previewImage = rescale.filter(originalImage, null);
ImageIO.write(previewImage, extension, previewPath.toFile());
}
private static void mkdir(@NonNull final Path directory) {
if (directory.toFile().mkdirs()) {
log.info("Directory created: {}", directory);
}
}
private static BufferedImage transformImageByOrientation(BufferedImage image, int orientation) {
int w = image.getWidth();
int h = image.getHeight();
final AffineTransform tx = new AffineTransform();
switch (orientation) {
case 1:
return image;
case 2: // Spiegeln horizontal
tx.scale(-1, 1);
tx.translate(-w, 0);
break;
case 3: // 180°
tx.translate(w, h);
tx.rotate(Math.toRadians(180));
break;
case 4: // Spiegeln vertikal
tx.scale(1, -1);
tx.translate(0, -h);
break;
case 5: // 90° + Spiegelung horizontal
tx.translate(h, 0);
tx.rotate(Math.toRadians(90));
tx.scale(-1, 1);
break;
case 6: // 90°
tx.translate(h, 0);
tx.rotate(Math.toRadians(90));
break;
case 7: // 270° + Spiegelung horizontal
tx.translate(0, w);
tx.rotate(Math.toRadians(270));
tx.scale(-1, 1);
break;
case 8: // 270°
tx.translate(0, w);
tx.rotate(Math.toRadians(270));
break;
default:
return image;
}
// Falls rotiert: Breite/Höhe ggf. tauschen
boolean swapDim = (orientation == 5 || orientation == 6 || orientation == 7 || orientation == 8);
int newW = swapDim ? h : w;
int newH = swapDim ? w : h;
// Transformation anwenden
final AffineTransformOp op = new AffineTransformOp(tx, AffineTransformOp.TYPE_BICUBIC);
final BufferedImage transformed = new BufferedImage(newW, newH, image.getType());
op.filter(image, transformed);
return transformed;
}
}

View File

@ -0,0 +1,27 @@
package de.ph87.isabell_und_timo.picture;
import jakarta.annotation.Nullable;
import lombok.Data;
import lombok.NonNull;
import java.time.ZonedDateTime;
@Data
public class PictureDto {
@NonNull
public final String uuid;
@Nullable
public final ZonedDateTime originalDate;
@NonNull
public final ZonedDateTime uploadDate;
public PictureDto(@NonNull final Picture picture) {
this.uuid = picture.getUuid();
this.originalDate = picture.getOriginalDate();
this.uploadDate = picture.getUploadDate();
}
}

View File

@ -0,0 +1,75 @@
package de.ph87.isabell_und_timo.picture;
import jakarta.annotation.Nullable;
import jakarta.persistence.Id;
import lombok.Data;
import lombok.NonNull;
import java.nio.file.Path;
import java.time.ZonedDateTime;
@Data
public class PictureInternal {
@Id
public final String uuid;
@NonNull
public final String originalFilename;
@Nullable
public final ZonedDateTime originalDate;
public final long size;
public final int height;
public final int width;
@Nullable
public final String make;
@Nullable
public final String model;
@NonNull
public final String uploadFilename;
@NonNull
public final ZonedDateTime uploadDate;
@NonNull
public final String uploadAddress;
@Nullable
public final String uploadAgent;
public final boolean visible;
public PictureInternal(@NonNull final Picture picture) {
this.uuid = picture.getUuid();
this.originalFilename = picture.getOriginalFilename();
this.originalDate = picture.getOriginalDate();
this.size = picture.getSize();
this.height = picture.getHeight();
this.width = picture.getWidth();
this.make = picture.getMake();
this.model = picture.getModel();
this.uploadFilename = picture.getUploadFilename();
this.uploadDate = picture.getUploadDate();
this.uploadAddress = picture.getUploadAddress();
this.uploadAgent = picture.getUploadAgent();
this.visible = picture.isVisible();
}
@NonNull
public Path getOriginalPath() {
return CONFIG.ORIGINAL.resolve(uploadFilename);
}
@NonNull
public Path getPreviewPath() {
return CONFIG.RESIZED.resolve(uploadFilename);
}
}

View File

@ -0,0 +1,20 @@
package de.ph87.isabell_und_timo.picture;
import lombok.NonNull;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
import java.util.Optional;
public interface PictureRepository extends ListCrudRepository<Picture, String> {
@NonNull
@Query("select new de.ph87.isabell_und_timo.picture.PictureDto(p) from Picture p where p.visible = true")
List<PictureDto> findAllDtoByVisibleTrue();
@NonNull
@Query("select new de.ph87.isabell_und_timo.picture.PictureInternal(p) from Picture p where p.uuid = :uuid")
Optional<PictureInternal> findDtoByUuid(@NonNull String uuid);
}

View File

@ -0,0 +1,49 @@
package de.ph87.isabell_und_timo.picture;
import jakarta.annotation.Nullable;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.ZonedDateTime;
@Slf4j
@Service
@RequiredArgsConstructor
public class PictureService {
private final PictureRepository pictureRepository;
private final ResourcePatternResolver resourcePatternResolver;
@NonNull
@Transactional
public PictureInternal create(
@Nullable final ZonedDateTime date,
@Nullable final String make,
@Nullable final String model,
final int width,
final int height,
final long size,
@NonNull final String originalFilename,
@NonNull final String uploadFilename,
@NonNull final ZonedDateTime uploadDate,
@NonNull final String uploadAddress,
@Nullable final String uploadAgent
) {
final Picture picture = pictureRepository.save(new Picture(originalFilename, date, size, width, height, make, model, uploadFilename, uploadDate, uploadAddress, uploadAgent));
log.info("Uploaded: {}", picture);
return new PictureInternal(picture);
}
public void visible(final @NonNull String uuid, final boolean visible) {
final Picture picture = pictureRepository.findById(uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
picture.setVisible(visible);
}
}

View File

@ -0,0 +1,8 @@
logging.level.root=WARN
logging.level.de.ph87=INFO
#-
spring.jpa.hibernate.naming.implicit-strategy=org.hibernate.boot.model.naming.ImplicitNamingStrategyComponentPathImpl
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false
#-
spring.main.banner-mode=off