Compare commits

...

34 Commits

Author SHA1 Message Date
11d14afb00 working on curl request
Some checks failed
Build Docker Image / Docker-Build (push) Failing after 2m25s
Build Docker Image / Clean-Registry (push) Successful in 2s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-05 14:01:55 +01:00
77e546bd4b fixed typo
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-05 13:54:12 +01:00
beb58bbc36 added remote call to restart vj.srsoftware.de
Some checks failed
Build Docker Image / Docker-Build (push) Failing after 2m24s
Build Docker Image / Clean-Registry (push) Successful in 3s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-05 13:52:31 +01:00
b17275a623 dropped workflow branch from trigger list
All checks were successful
Build Docker Image / Docker-Build (push) Successful in 2m40s
Build Docker Image / Clean-Registry (push) Successful in 4s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 23:54:41 +01:00
c1adce5a9e overhauling image creation and management
All checks were successful
Build Docker Image / Docker-Build (push) Successful in 2m31s
Build Docker Image / Clean-Registry (push) Successful in 3s
2026-01-03 23:53:20 +01:00
a278f73c2e bugfix: forgot to load date
Some checks failed
Build Docker Image / Docker-Build (push) Failing after 2m29s
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:41:51 +01:00
218d14090c improved date-tagging
Some checks failed
Build Docker Image / Docker-Build (push) Has been cancelled
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:37:39 +01:00
6f2bbdf635 trying to pass date via tmp
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:32:53 +01:00
8cff93c469 dropped reatagging, adding date tag
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:23:23 +01:00
1efed60696 working on image retagging
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:13:36 +01:00
c6dac692de fixed syntax
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:07:34 +01:00
202d7deeb9 trying to re-tag image
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 21:06:31 +01:00
fc2e484aec added dev to branches that trigger commit
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 20:41:51 +01:00
bedb275fe3 disabled build on every push
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 18:15:39 +01:00
ff9b520449 Started workflow definition
All checks were successful
Build Docker Image / Docker-Build (push) Successful in 2m55s
2026-01-03 18:13:57 +01:00
2f4c939e93 created workflow file
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 15:47:40 +01:00
e596b0cdad starting Dockerfile for Git workflow
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2026-01-03 15:43:11 +01:00
e50702d0b3 Merge branch 'main' into module/messagebus 2025-12-28 14:10:18 +01:00
f5cd51bc3b improving CSS
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-12-28 14:09:59 +01:00
c05f5110e4 working on project list update by event 2025-12-22 14:42:41 +01:00
2d4def4265 fixing gradle bug, cleaning up
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-12-20 18:55:19 +01:00
18c7965b2e working on task update by event 2025-12-20 17:09:06 +01:00
f6813d8a75 working on task update by event 2025-12-20 16:35:47 +01:00
c05f24cd7c finished project tree update on task edit, added update of task view on task edit 2025-12-20 16:29:16 +01:00
5a1c8e1438 working on project tree update upon task creation/deletion/update 2025-12-20 15:11:18 +01:00
711addd75c updating kanban on
* task creation
* task update
* task deletion
2025-12-20 13:51:06 +01:00
78cae4644d simplified kanban:
Before this change tasks were resorted on drop. By introduction of server-sent events, this is no longer required, as resorting also happens triggered by the server side.
2025-12-20 13:21:17 +01:00
851df52458 re-ordering methods 2025-12-20 13:16:14 +01:00
bd2fb255d2 first working version with event reception in kanban 2025-12-20 00:43:01 +01:00
3b3803dafa working on event reception in Kanban 2025-12-19 16:07:30 +01:00
bad244ef16 working on message bus
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-12-19 14:06:31 +01:00
3d31ac90a0 preparing message bus 2025-12-19 13:33:08 +01:00
73751c1ea2 preparing message bus 2025-12-19 08:42:47 +01:00
a924f25f51 preparing event bus
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
2025-12-16 15:26:39 +01:00
36 changed files with 705 additions and 156 deletions

View File

@@ -0,0 +1,78 @@
name: Build Docker Image
run-name: ${{ gitea.actor }} building ${{ gitea.ref_name }}
on:
push:
branches:
- main
- dev
jobs:
Docker-Build:
runs-on: ubuntu-latest
steps:
- name: Clone Repository
uses: actions/checkout@v4
- name: Build docker image
run: docker build -t umbrella .
- name: Store tag date
run: |
TAG=$(date +%Y%m%d_%H%M)_${{ gitea.ref_name }}
echo $TAG > /tmp/tag
echo Using '"'$TAG'"' as tag.
- name: Tag image for upload
run: |
TAG=$(cat /tmp/tag)
docker tag umbrella ${{ secrets.REGISTRY_PATH }}/umbrella:${{ gitea.ref_name }}
docker tag umbrella ${{ secrets.REGISTRY_PATH }}/umbrella:$TAG
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ secrets.REGISTRY_PATH }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: Push to registry
run: |
TAG=$(cat /tmp/tag)
docker push ${{ secrets.REGISTRY_PATH }}/umbrella:${{ gitea.ref_name }}
docker push ${{ secrets.REGISTRY_PATH }}/umbrella:$TAG
- name: Restart vj.srsoftware.de
run: |
echo curl -X POST \
-H "Authorization: Bearer ${{ secrets.MAKE_BEARER }}" \
-d vj_start \
https://make.srsoftware.de/launch
Clean-Registry:
runs-on: ubuntu-latest
steps:
- name: Get tag list
run: |
TAGS="$(curl -s -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASS }}" https://${{ secrets.REGISTRY_PATH }}/v2/umbrella/tags/list | jq -r ".tags[]")"
COUNT=$(echo "$TAGS" | wc -l)
if [ $COUNT -gt 10 ]; then
REMAIN=$((COUNT - 10))
echo "$TAGS" | head -n $REMAIN > /tmp/old_tags
else
echo less than 10 tags, skipping cleanup
echo "" > /tmp/old_tags
fi
- name: Remove tags
run: |
cat /tmp/old_tags | while read TAG; do
if [ -n "$TAG" ]; then
DIGEST=$(curl -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASS }}" -sS -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -o /dev/null -w '%header{Docker-Content-Digest}' https://${{ secrets.REGISTRY_PATH }}/v2/umbrella/manifests/$TAG)
if [ -n "$DIGEST" ]; then
echo about to delete $TAG
curl -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASS }}" -sS -X DELETE https://${{ secrets.REGISTRY_PATH }}/v2/umbrella/manifests/$DIGEST
else
echo failed to get digest for $TAG
fi
fi
done

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM alpine:3.22 AS svelte_build
RUN apk add bash git npm
RUN adduser -Dh /home/svelte svelte
ADD . /home/svelte/Umbrella
RUN chown -R svelte /home/svelte/Umbrella
USER svelte
WORKDIR /home/svelte/Umbrella/frontend
RUN npm install && npm run build
FROM alpine AS java_build
RUN apk add bash git gradle fontconfig font-opensans openjdk21-jre
ADD . /Umbrella
WORKDIR /Umbrella
COPY --from=svelte_build /home/svelte/Umbrella/frontend/dist web/src/main/resources/web
RUN gradle --no-daemon build
FROM alpine
RUN apk add bash fontconfig font-opensans graphviz openjdk21-jre weasyprint
RUN adduser -D umbrella
COPY --from=java_build /Umbrella/backend/build/libs/backend.jar /home/umbrella/jar/
RUN chown -R umbrella /home/umbrella
ADD https://github.com/plantuml/plantuml/releases/download/v1.2025.10/plantuml-1.2025.10.jar /home/umbrella/plantuml.jar
USER umbrella
WORKDIR /home/umbrella
RUN mkdir .config && ln -s /host/config.json .config/Umbrella.json
EXPOSE 80
CMD java -jar jar/backend.jar

View File

@@ -12,6 +12,7 @@ application{
dependencies{
implementation(project(":bookmark"));
implementation(project(":bus"));
implementation(project(":company"))
implementation(project(":contact"))
implementation(project(":core"))
@@ -45,6 +46,7 @@ tasks.jar {
from(dependencies)
dependsOn(
":bookmark:jar",
":bus:jar",
":company:jar",
":contact:jar",
":core:jar",

View File

@@ -19,6 +19,7 @@ import de.srsoftware.umbrella.files.FileModule;
import de.srsoftware.umbrella.legacy.*;
import de.srsoftware.umbrella.markdown.MarkdownApi;
import de.srsoftware.umbrella.message.MessageSystem;
import de.srsoftware.umbrella.messagebus.MessageApi;
import de.srsoftware.umbrella.notes.NoteModule;
import de.srsoftware.umbrella.project.ProjectModule;
import de.srsoftware.umbrella.stock.StockModule;
@@ -63,6 +64,7 @@ public class Application {
var server = HttpServer.create(new InetSocketAddress(port), 0);
try {
new Translations().bindPath("/api/translations").on(server);
new MessageApi().bindPath("/api/bus").on(server);
new MessageSystem(config);
new UserModule(config).bindPath("/api/user").on(server);
new TagModule(config).bindPath("/api/tags").on(server);

View File

@@ -43,7 +43,7 @@ subprojects {
implementation("de.srsoftware:configuration.api:1.0.2")
implementation("de.srsoftware:tools.jdbc:2.0.7")
implementation("de.srsoftware:tools.http:6.0.5")
implementation("de.srsoftware:tools.mime:1.1.3")
implementation("de.srsoftware:tools.mime:1.1.4")
implementation("de.srsoftware:tools.logging:1.3.2")
implementation("de.srsoftware:tools.optionals:1.0.0")
implementation("de.srsoftware:tools.util:2.0.4")

6
bus/build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
description = "Umbrella : Messagebus"
dependencies{
implementation(project(":core"))
}

View File

@@ -0,0 +1,8 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus;
import de.srsoftware.umbrella.messagebus.events.Event;
public interface EventListener {
void onEvent(Event event);
}

View File

@@ -0,0 +1,36 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus;
import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
import de.srsoftware.umbrella.messagebus.events.Event;
import java.net.InetSocketAddress;
import java.util.LinkedList;
public class EventQueue extends LinkedList<Event> implements AutoCloseable, EventListener {
private final InetSocketAddress addr;
public EventQueue(InetSocketAddress addr){
this.addr = addr;
messageBus().register(this);
}
public void close() {
messageBus().drop(this);
}
@Override
public int hashCode() {
return addr.hashCode();
}
@Override
public void onEvent(Event event) {
System.getLogger(addr.toString()).log(System.Logger.Level.INFO,"adding event to queue of {1}: {0}",event.eventType(),addr);
add(event);
}
}

View File

@@ -0,0 +1,84 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static java.lang.System.Logger.Level.*;
import static java.lang.Thread.sleep;
import static java.net.HttpURLConnection.HTTP_OK;
import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.tools.MimeType;
import de.srsoftware.tools.Path;
import de.srsoftware.tools.SessionToken;
import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.Token;
import de.srsoftware.umbrella.messagebus.events.Event;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.util.Optional;
public class MessageApi extends BaseHandler{
private static final System.Logger LOG = System.getLogger(MessageApi.class.getSimpleName());
@Override
public boolean doGet(Path path, HttpExchange ex) throws IOException {
addCors(ex);
Optional<Token> token = SessionToken.from(ex).map(Token::of);
var user = userService().loadUser(token);
if (user.isEmpty()) return unauthorized(ex); // TODO
var headers = ex.getResponseHeaders();
var addr = ex.getRemoteAddress();
headers.add(CONTENT_TYPE, MimeType.MIME_EVENT_STREAM);
headers.add(CACHE_CONTROL,NO_CACHE);
headers.add(CONNECTION,KEEP_ALIVE);
headers.add(CONTENT_ENCODING,NONE);
ex.sendResponseHeaders(HTTP_OK,0);
try (var os = ex.getResponseBody(); var stream = new PrintWriter(os); var eventQueue = new EventQueue(addr)){
LOG.log(INFO,"{0} opened event stream.",addr);
var counter = 0;
while (!stream.checkError()){
sleep(100);
if (eventQueue.isEmpty()){
if (++counter > 300) counter = sendBeacon(addr,stream);
} else {
var event = eventQueue.removeFirst();
//if (event.isIntendedFor(user.get())) {
LOG.log(DEBUG, "sending event to {0}", addr);
sendEvent(stream, event);
counter = 0;
//}
}
}
LOG.log(INFO,"{0} disconnected from event stream.",addr);
return true;
} catch (InterruptedException e) {
throw new UmbrellaException("EventStream broken").causedBy(e);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private void sendEvent(PrintWriter out, Event event) {
if (event == null) return;
out.print("event: ");
out.println(event.eventType());
out.print("data: ");
out.println(event.json());
out.println();
out.println(); // terminate message with empty line
out.flush();
}
private int sendBeacon(InetSocketAddress addr, PrintWriter stream) {
LOG.log(DEBUG,"sending keep-alive to {0}",addr);
stream.print(": keep-alive\n\n");
stream.flush();
return 0;
}
}

View File

@@ -0,0 +1,35 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus;
import de.srsoftware.umbrella.messagebus.events.Event;
import java.util.HashSet;
import java.util.Set;
public class MessageBus {
private static MessageBus SINGLETON = new MessageBus();
private Set<EventListener> listeners = new HashSet<>();
private MessageBus(){}
public void dispatch(Event event){
new Thread(() -> { // TODO: use thread pool
try {
Thread.sleep(100);
listeners.parallelStream().forEach(l -> l.onEvent(event));
} catch (InterruptedException ignored) {
}
}).start();
}
public void drop(EventListener listener){
listeners.remove(listener);
}
public static MessageBus messageBus(){
return SINGLETON;
}
public void register(EventListener listener){
listeners.add(listener);
}
}

View File

@@ -0,0 +1,54 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus.events;
import static de.srsoftware.umbrella.core.Constants.USER;
import de.srsoftware.tools.Mappable;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import java.util.Map;
import org.json.JSONObject;
public abstract class Event<Payload extends Mappable> {
public enum EventType {
CREATE,
UPDATE,
DELETE;
}
private UmbrellaUser initiator;
private String realm;
private Payload payload;
private EventType eventType;
public Event(UmbrellaUser initiator, String realm, Payload payload, EventType type){
this.initiator = initiator;
this.realm = realm;
this.payload = payload;
this.eventType = type;
}
public String eventType(){
return eventType.toString();
}
public abstract boolean isIntendedFor(UmbrellaUser user);
public String json(){
Class<?> clazz = payload.getClass();
{ // get the highest superclass that is not object
Class<?> parent = clazz.getSuperclass();
while (parent != null && parent != Object.class) {
clazz = parent;
parent = clazz.getSuperclass();
}
}
var map = Map.of(USER,initiator.toMap(),clazz.getSimpleName().toLowerCase(),payload.toMap());
return new JSONObject(map).toString();
}
public Payload payload(){
return payload;
}
}

View File

@@ -0,0 +1,22 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus.events;
import static de.srsoftware.umbrella.core.Constants.PROJECT;
import de.srsoftware.umbrella.core.model.Project;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
public class ProjectEvent extends Event<Project>{
public ProjectEvent(UmbrellaUser initiator, Project project, EventType type){
super(initiator, PROJECT, project, type);
}
@Override
public boolean isIntendedFor(UmbrellaUser user) {
for (var member : payload().members().values()){
if (member.user().equals(user)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,22 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.messagebus.events;
import static de.srsoftware.umbrella.core.Constants.TASK;
import de.srsoftware.umbrella.core.model.Task;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
public class TaskEvent extends Event<Task>{
public TaskEvent(UmbrellaUser initiator, Task task, EventType type){
super(initiator, TASK, task, type);
}
@Override
public boolean isIntendedFor(UmbrellaUser user) {
for (var member : payload().members().values()){
if (member.user().equals(user)) return true;
}
return false;
}
}

View File

@@ -20,12 +20,15 @@ public class Constants {
public static final String BODY = "body";
public static final String BOOKMARK = "bookmark";
public static final String CACHE_CONTROL = "Cache-Control";
public static final String CODE = "code";
public static final String COMMENT = "comment";
public static final String COMPANY = "company";
public static final String COMPANY_ID = "company_id";
public static final String CONNECTION = "Connection";
public static final String CONTENT = "content";
public static final String CONTENT_DISPOSITION = "Content-Disposition";
public static final String CONTENT_ENCODING = "Content-Encoding";
public static final String CONTENT_TYPE = "Content-Type";
public static final String CUSTOMER_NUMBER_PREFIX = "customer_number_prefix";
@@ -67,7 +70,9 @@ public class Constants {
public static final String JSONARRAY = "json array";
public static final String JSONOBJECT = "json object";
public static final String KEY = "key";
public static final String KEEP_ALIVE = "keep-alive";
public static final String KEY = "key";
public static final String LANGUAGE = "language";
public static final String LAST_CUSTOMER_NUMBER = "last_customer_number";
@@ -80,12 +85,14 @@ public class Constants {
public static final String MESSAGES = "messages";
public static final String MODULE = "module";
public static final String NAME = "name";
public static final String NAME = "name";
public static final String NEW_MEMBER = "new_member";
public static final String MIME = "mime";
public static final String NO_INDEX = "no_index";
public static final String NOTE = "note";
public static final String NUMBER = "number";
public static final String MIME = "mime";
public static final String NO_CACHE = "no-cache";
public static final String NO_INDEX = "no_index";
public static final String NONE = "none";
public static final String NOTE = "note";
public static final String NUMBER = "number";
public static final String OFFSET = "offset";
public static final String OPTIONAL = "optional";
@@ -123,13 +130,14 @@ public class Constants {
public static final String SUBJECT = "subject";
public static final String TABLE_SETTINGS = "settings";
public static final String TAGS = "tags";
public static final String TAGS = "tags";
public static final String TAG_COLORS = "tag_colors";
public static final String TASK_IDS = "task_ids";
public static final String TAX = "tax";
public static final String TAX_RATE = "tax_rate";
public static final String TEMPLATE = "template";
public static final String TEXT = "text";
public static final String TASK = "task";
public static final String TASK_IDS = "task_ids";
public static final String TAX = "tax";
public static final String TAX_RATE = "tax_rate";
public static final String TEMPLATE = "template";
public static final String TEXT = "text";
public static final String THOUSANDS_SEPARATOR = "thousands_separator";
public static final String THEME = "theme";
public static final String TITLE = "title";

View File

@@ -9,6 +9,7 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="/src/main.js">
</script>
</body>
</html>

View File

@@ -79,7 +79,7 @@
<span class="error">{@html messages.error}</span>
{/if}
{#if messages.warning}
<span class="error">{@html messages.warning}</span>
<span class="warn">{@html messages.warning}</span>
{/if}
<Route path="/" component={User} />
<Route path="/bookmark" component={Bookmarks} />
@@ -130,7 +130,7 @@
<span class="error">{@html messages.error}</span>
{/if}
{#if messages.warning}
<span class="error">{@html messages.warning}</span>
<span class="warn">{@html messages.warning}</span>
{/if}
<Route path="/user/reset/pw" component={ResetPw} />
<Route path="/oidc_callback" component={Callback} />

View File

@@ -116,6 +116,6 @@
</div>
{/if}
{:else}
<svelte:element this={type} {onclick} {oncontextmenu} class={{editable}} title={t('right_click_to_edit')} >{@html target(editValue.rendered)}</svelte:element>
<svelte:element this={type} {onclick} {oncontextmenu} class={{editable}} title={t('right_click_to_edit')} >{@html target(value.rendered)}</svelte:element>
{/if}
</div>

View File

@@ -1,9 +1,9 @@
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api, target } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { api, eventStream, target } from '../../urls.svelte.js';
import { error, messages, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
import { user } from '../../user.svelte.js';
@@ -11,10 +11,12 @@
import LineEditor from '../../Components/LineEditor.svelte';
import MarkdownEditor from '../../Components/MarkdownEditor.svelte';
let { id } = $props();
let descr = $state(false);
let filter_input = $state('');
let router = useTinyRouter();
let eventSource = null;
let connectionStatus = 'disconnected';
let { id } = $props();
let descr = $state(false);
let filter_input = $state('');
let router = useTinyRouter();
if (router.hasQueryParam('filter')) filter_input = router.getQueryParam('filter');
let dragged = null;
let highlight = $state({});
@@ -23,34 +25,11 @@
let tasks = $state({});
let users = [];
let columns = $derived(project.allowed_states?Object.keys(project.allowed_states).length+1:1);
let info = $state(null);
let stateList = {};
$effect(() => updateUrl(filter_input));
async function do_archive(ex){
ex.preventDefault();
var task = dragged;
const url = api(`task/${task.id}`);
const resp = await fetch(url,{
credentials : 'include',
method : 'PATCH',
body : JSON.stringify({no_index:true})
});
delete highlight.archive;
if (resp.ok){
yikes();
delete tasks[task.assignee][task.status][task.id]
} else {
error(resp);
}
}
function updateUrl(){
let url = window.location.origin + window.location.pathname;
if (filter_input) url += '?filter=' + encodeURI(filter_input);
window.history.replaceState(window.history.state, '', url);
}
function byName(a,b) {
return a.name.localeCompare(b.name);
}
@@ -85,6 +64,24 @@
}
}
async function do_archive(ex){
ex.preventDefault();
var task = dragged;
const url = api(`task/${task.id}`);
const resp = await fetch(url,{
credentials : 'include',
method : 'PATCH',
body : JSON.stringify({no_index:true})
});
delete highlight.archive;
if (resp.ok){
yikes();
delete tasks[task.assignee][task.status][task.id]
} else {
error(resp);
}
}
async function drop(user_id,state){
let task = dragged;
dragged = null;
@@ -100,20 +97,64 @@
body : JSON.stringify(patch)
});
if (resp.ok){
delete tasks[task.assignee][task.status][task.id]
if (!tasks[user_id]) tasks[user_id] = {}
if (!tasks[user_id][state]) tasks[user_id][state] = {}
tasks[user_id][state][task.id] = task;
task.assignee = user_id;
task.status = state;
yikes();
} else {
error(resp);
}
}
function handleCreateEvent(evt){
handleEvent(evt,'create');
}
function handleEvent(evt,method){
let json = JSON.parse(evt.data);
if (json.task && json.user){
// drop from kanban
for (let uid in tasks){
if (!uid) continue;
for (let state in tasks[uid]) delete tasks[uid][state][json.task.id];
}
// (re) add to kanban
if (method != 'delete') processTask(json.task);
// show notification
if (json.user.id != user.id) {
let term = "user_updated_entity";
if (method == 'create') term = "user_created_entity";
if (method == 'delete') term = "user_deleted_entity";
info = t(term,{user:json.user.name,entity:json.task.name});
setTimeout(() => { info = null; },2500);
}
}
}
function handleDeleteEvent(evt){
handleEvent(evt,'delete');
}
function handleUpdateEvent(evt){
handleEvent(evt,'update');
}
function hover(ev,user_id,state){
ev.preventDefault();
highlight = {user:user_id,state:state};
}
function hover_archive(ev){
ev.preventDefault();
highlight.archive = true;
}
function is_custom(state){
return [10,20,40,60,100].includes(state);
}
async function load(){
try {
eventSource = eventStream(handleCreateEvent,handleUpdateEvent,handleDeleteEvent);
await loadProject();
loadTasks({project_id:+id,parent_task_id:0});
} catch (ignored) {}
@@ -155,55 +196,46 @@
var json = await resp.json();
for (var task_id of Object.keys(json)) {
let task = json[task_id];
if (task.no_index) continue;
let state = +task.status;
let owner = null;
let assignee = null;
for (var user_id of Object.keys(task.members)){
var member = task.members[user_id];
if (member.permission.name == 'OWNER') owner = +user_id;
if (member.permission.name == 'ASSIGNEE') assignee = +user_id;
}
if (!assignee) assignee = owner;
task.assignee = assignee;
if (!tasks[assignee]) tasks[assignee] = {};
if (!tasks[assignee][state]) tasks[assignee][state] = {};
tasks[assignee][state][task_id] = task;
processTask(task);
}
} else {
error(resp);
}
}
function hover(ev,user_id,state){
ev.preventDefault();
highlight = {user:user_id,state:state};
}
function hover_archive(ev){
ev.preventDefault();
highlight.archive = true;
}
function is_custom(state){
return [10,20,40,60,100].includes(state);
}
function openTask(task_id){
window.open(`/task/${task_id}/view`, '_blank').focus();
}
function processTask(task){
if (task.no_index) return;
let state = +task.status;
let owner = null;
let assignee = null;
for (var user_id of Object.keys(task.members)){
var member = task.members[user_id];
if (member.permission.name == 'OWNER') owner = +user_id;
if (member.permission.name == 'ASSIGNEE') assignee = +user_id;
}
if (!assignee) assignee = owner;
task.assignee = assignee;
if (!tasks[assignee]) tasks[assignee] = {};
if (!tasks[assignee][state]) tasks[assignee][state] = {};
tasks[assignee][state][task.id] = task;
}
async function save_bookmark(){
const user_ids = Object.values(project.members).map(member => member.user.id);
const data = {
url: location.href,
tags: ['Kanban', project.name, filter_input],
comment: `${project.name}: ${filter_input}`,
share: user_ids
url : location.href,
tags : ['Kanban', project.name, filter_input],
comment : `${project.name}: ${filter_input}`,
share : user_ids
}
const url = api('bookmark');
const url = api('bookmark');
const resp = await fetch(url,{
credentials : 'include',
method : 'POST',
@@ -217,6 +249,16 @@
}
}
function updateUrl(){
let url = window.location.origin + window.location.pathname;
if (filter_input) url += '?filter=' + encodeURI(filter_input);
window.history.replaceState(window.history.state, '', url);
}
onDestroy(() => {
if (eventSource) eventSource.close();
});
onMount(load);
</script>
@@ -227,7 +269,9 @@
{#if project}
<h1 onclick={ev => router.navigate(`/project/${project.id}/view`)}>{project.name}</h1>
{/if}
{#if info}
<div class="info">{info}</div>
{/if}
{#if project}
<fieldset class="kanban description {descr?'active':''}" onclick={e => descr = !descr}>
<legend>{t('description')} {t('expand_on_click')}</legend>

View File

@@ -1,19 +1,26 @@
<script>
import { onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { onDestroy, onMount } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
import { api, eventStream } from '../../urls.svelte.js';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte.js';
const router = useTinyRouter();
let events = null;
let projects = $state(null);
let companies = $state(null);
let showClosed = $state(router.query.closed == "show");
let sortedProjects = $derived.by(() => Object.values(projects).sort((a, b) => a.name.localeCompare(b.name)));
function handleUpdate(evt){
let json = JSON.parse(evt.data);
if (json.project) projects[json.project.id] = json.project;
}
async function loadProjects(){
events = eventStream(handleUpdate,handleUpdate,null);
let url = api('company/list');
let resp = await fetch(url,{credentials:'include'});
if (resp.ok){
@@ -66,6 +73,9 @@
}
onMount(loadProjects);
onDestroy(() => {
if (events) events.close();
})
</script>
<svelte:head>

View File

@@ -1,8 +1,8 @@
<script>
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../../urls.svelte';
import { api, eventStream } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte';
@@ -14,7 +14,9 @@
import Tags from '../tags/TagList.svelte';
import TaskList from '../task/TaskList.svelte';
let eventSource = $state(null);
let { id } = $props();
let lastEvent = $state(null);
let estimated_time = $state({sum:0});
let project = $state(null);
let router = useTinyRouter();
@@ -80,10 +82,34 @@
}
}
function handleCreate(evt){
let json = JSON.parse(evt.data);
json.event = 'create';
lastEvent = json;
}
function handleDelete(evt){
let json = JSON.parse(evt.data);
json.event = 'delete';
lastEvent = json;
}
function handleUpdate(evt){
let json = JSON.parse(evt.data);
json.event = 'update';
lastEvent = json;
if (json.project && json.project.id == project.id) project = json.project;
}
function kanban(){
router.navigate(`/project/${id}/kanban`);
}
function load(){
eventSource = eventStream(handleCreate,handleUpdate,handleDelete);
loadProject();
}
async function loadProject(){
const url = api(`project/${id}`);
const resp = await fetch(url,{credentials:'include'});
@@ -162,7 +188,20 @@
window.open(`/time?project=${id}`, '_blank').focus();
}
onMount(loadProject);
$effect(() => {
if (lastEvent && lastEvent.task) {
if (lastEvent.event == 'delete' || lastEvent.task.parent_task_id){
delete tasks[lastEvent.task.id];
} else {
tasks[lastEvent.task.id] = lastEvent.task;
}
}
});
onMount(load);
onDestroy(() => {
if (eventSource) eventSource.close();
});
</script>
<svelte:head>
@@ -265,7 +304,7 @@
</div>
<div class="tasks">
{#if tasks}
<TaskList {tasks} {estimated_time} states={project?.allowed_states} show_closed={show_closed || project.show_closed} />
<TaskList {tasks} {estimated_time} {lastEvent} {eventSource} states={project?.allowed_states} show_closed={show_closed || project.show_closed} />
{/if}
</div>
</div>

View File

@@ -14,6 +14,7 @@
let {
estimated_time,
lastEvent,
show_closed,
siblings,
states = {},
@@ -53,7 +54,7 @@
if (resp.ok){
deleted = true;
} else {
error(resp);
error(resp);
}
}
}
@@ -75,8 +76,7 @@
body : JSON.stringify({ parent_task_id : task.id})
});
if (resp.ok) {
children[dragged.element.id]=dragged.element;
if (dragged.siblings[dragged.element.id]) delete dragged.siblings[dragged.element.id];
yikes();
} else {
error(resp);
}
@@ -141,6 +141,16 @@
estimated_time.sum += task.estimated_time;
}
$effect(() => {
if (children && lastEvent && lastEvent.task) {
if (lastEvent.event == 'delete' || lastEvent.task.parent_task_id != task.id){
delete children[lastEvent.task.id];
} else {
children[lastEvent.task.id] = lastEvent.task;
}
}
});
onMount(loadChildren);
</script>
@@ -170,7 +180,7 @@
<button class="symbol" title={t('add_object',{object:t('subtask')})} onclick={addSubtask}></button>
<button class="symbol" title={t('timetracking')} onclick={addTime}></button>
{#if children}
<TaskList {states} tasks={children} {estimated_time} {show_closed} />
<TaskList {states} {lastEvent} tasks={children} {estimated_time} {show_closed} />
{/if}
</li>
{/if}

View File

@@ -2,14 +2,13 @@
import { t } from '../../translations.svelte.js';
import ListTask from './ListTask.svelte';
let { estimated_time, show_closed, states = {}, tasks } = $props();
let { estimated_time, lastEvent, show_closed, states = {}, tasks } = $props();
let sortedTasks = $derived.by(() => Object.values(tasks).sort((a, b) => a.name.localeCompare(b.name)));
</script>
<ul>
{#each sortedTasks as task}
<ListTask {states} {task} siblings={tasks} {estimated_time} show_closed={show_closed || task.show_closed} />
<ListTask {states} {task} {lastEvent} siblings={tasks} {estimated_time} show_closed={show_closed || task.show_closed} />
{/each}
</ul>

View File

@@ -1,8 +1,8 @@
<script>
import { onMount } from 'svelte';
import { onDestroy } from 'svelte';
import { useTinyRouter } from 'svelte-tiny-router';
import { api } from '../../urls.svelte';
import { api, eventStream } from '../../urls.svelte';
import { error, yikes } from '../../warn.svelte';
import { t } from '../../translations.svelte';
import { timetrack } from '../../user.svelte.js';
@@ -17,6 +17,7 @@
import TagList from '../tags/TagList.svelte';
import TaskList from './TaskList.svelte';
let eventSource = $state(null);
let { id } = $props();
let children = $state(null);
let dummy = $derived(updateOn(id));
@@ -27,7 +28,10 @@
let show_closed = $state(false);
let task = $state(null);
$effect(() => updateOn(id));
$effect(() => {
if (!eventSource) eventSource = eventStream(null,handleUpdateEvent,null);
updateOn(id)
});
function addChild(){
router.navigate(`/task/${id}/add_subtask`);
@@ -82,7 +86,15 @@
router.navigate(`/project/${project.id}/view`)
}
async function loadChildren(){
function handleUpdateEvent(evt){
let json = JSON.parse(evt.data);
if (json.task) {
if (json.task.id == id) updateOn(id);
if (json.task.parent_task_id == id) children[json.task.id] = json.task;
}
}
async function loadChildren(){
const url = api('task/list');
const data = {
parent_task_id : +task.id,
@@ -94,8 +106,8 @@
body:JSON.stringify(data)
});
if (resp.ok){
children = await resp.json();
yikes();
children = await resp.json();
} else {
error(resp);
}
@@ -117,8 +129,6 @@
if (resp.ok){
yikes();
task = await resp.json();
project = null;
children = null;
if (task.show_closed) show_closed = true;
loadChildren();
if (task.project_id) loadProject();
@@ -167,13 +177,6 @@
});
if (resp.ok){
yikes();
let old_task = task;
task = await resp.json();
if (!task.parent_id){
task.parent = null;
} else {
if (task.parent_id == old_task.parent_id) task.parent = old_task.parent;
}
return true;
} else {
error(resp);
@@ -190,7 +193,7 @@
}
function updateOn(id){
task = null;
//task = null;
loadTask();
}
@@ -199,6 +202,10 @@
members[user_id] = permission.code;
update({members:members});
}
onDestroy(() => {
if (eventSource) eventSource.close();
});
</script>
<svelte:head>
@@ -313,7 +320,7 @@
</div>
<div class="children">
{#if children}
<TaskList states={project?.allowed_states} tasks={children} {estimated_time} show_closed={task.show_closed} />
<TaskList {eventSource} states={project?.allowed_states} tasks={children} {estimated_time} show_closed={task.show_closed} />
{/if}
</div>

View File

@@ -15,6 +15,14 @@ export function drop(url){
});
}
export function eventStream(createHandler,updateHandler,deleteHandler){
const es = new EventSource(api('bus'), {withCredentials: true});
if (createHandler) es.addEventListener('CREATE', createHandler);
if (updateHandler) es.addEventListener('UPDATE', updateHandler);
if (deleteHandler) es.addEventListener('DELETE', deleteHandler);
return es;
}
export function patch(url,data){
return fetch(url,{
credentials : 'include',

View File

@@ -1,5 +1,6 @@
description = "Umbrella : Projects"
dependencies{
implementation(project(":bus"))
implementation(project(":core"))
}

View File

@@ -12,6 +12,9 @@ import static de.srsoftware.umbrella.core.model.Permission.*;
import static de.srsoftware.umbrella.core.model.Permission.OWNER;
import static de.srsoftware.umbrella.core.model.Status.OPEN;
import static de.srsoftware.umbrella.core.model.Status.PREDEFINED;
import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.UPDATE;
import static de.srsoftware.umbrella.project.Constants.CONFIG_DATABASE;
import static java.lang.Boolean.TRUE;
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
@@ -25,6 +28,7 @@ import de.srsoftware.umbrella.core.ModuleRegistry;
import de.srsoftware.umbrella.core.api.ProjectService;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.*;
import de.srsoftware.umbrella.messagebus.events.ProjectEvent;
import java.io.IOException;
import java.util.*;
import org.json.JSONArray;
@@ -211,7 +215,8 @@ public class ProjectModule extends BaseHandler implements ProjectService {
if (json.has(MEMBERS) && json.get(MEMBERS) instanceof JSONObject memberJson) patchMembers(project,memberJson);
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) addMember(project,num.longValue());
projectDb.save(project.patch(json), user);
project = projectDb.save(project.patch(json), user);
messageBus().dispatch(new ProjectEvent(user,project, UPDATE));
return sendContent(ex,project.toMap());
}
@@ -252,7 +257,7 @@ public class ProjectModule extends BaseHandler implements ProjectService {
var tagList = arr.toList().stream().filter(elem -> elem instanceof String).map(String.class::cast).toList();
tagService().save(PROJECT,prj.id(),null,tagList);
}
messageBus().dispatch(new ProjectEvent(user,prj, CREATE));
return sendContent(ex,prj);
}

View File

@@ -2,6 +2,7 @@ rootProject.name = "Umbrella25"
include("backend")
include("bookmark")
include("bus")
include("company")
include("contact")
include("core")

View File

@@ -1,6 +1,7 @@
description = "Umbrella : Tasks"
dependencies{
implementation(project(":bus"))
implementation(project(":core"))
implementation(project(":project"))
}
}

View File

@@ -12,7 +12,6 @@ public class Constants {
public static final String TABLE_TASK_DEPENDENCIES = "task_dependencies";
public static final String TABLE_TASKS = "tasks";
public static final String TABLE_TASKS_USERS = "tasks_users";
public static final String TASK = "task";
public static final String TASKS = "tasks";
public static final String TASK_ID = "task_id";
}

View File

@@ -12,7 +12,6 @@ import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.notFound;
import static de.srsoftware.umbrella.core.model.Status.*;
import static de.srsoftware.umbrella.project.Constants.*;
import static de.srsoftware.umbrella.task.Constants.*;
import static java.lang.System.Logger.Level.*;
import static java.text.MessageFormat.format;
import de.srsoftware.tools.jdbc.Query;

View File

@@ -11,6 +11,9 @@ import static de.srsoftware.umbrella.core.Util.mapValues;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
import static de.srsoftware.umbrella.core.model.Permission.*;
import static de.srsoftware.umbrella.core.model.Permission.OWNER;
import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.UPDATE;
import static de.srsoftware.umbrella.project.Constants.PERMISSIONS;
import static de.srsoftware.umbrella.task.Constants.*;
import static java.lang.System.Logger.Level.WARNING;
@@ -29,6 +32,8 @@ import de.srsoftware.umbrella.core.model.*;
import de.srsoftware.umbrella.core.model.Task;
import de.srsoftware.umbrella.core.model.Token;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import de.srsoftware.umbrella.messagebus.events.Event;
import de.srsoftware.umbrella.messagebus.events.TaskEvent;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@@ -37,6 +42,22 @@ import org.json.JSONObject;
public class TaskModule extends BaseHandler implements TaskService {
private static class TaggedTask extends Task{
private final Collection<String> tags;
public TaggedTask(Task task, Collection<String> tags) {
super(task.id(), task.projectId(), task.parentTaskId(), task.name(), task.description(), task.status(), task.estimatedTime(), task.start(), task.dueDate(), task.showClosed(), task.noIndex(), task.members(), task.priority());
this.tags = tags;
}
@Override
public Map<String, Object> toMap() {
var map = super.toMap();
map.put(TAGS,tags);
return map;
}
}
private final TaskDb taskDb;
public TaskModule(Configuration config) throws UmbrellaException {
@@ -60,6 +81,7 @@ public class TaskModule extends BaseHandler implements TaskService {
taskDb.delete(task);
noteService().deleteEntity(TASK, "" + taskId);
tagService().deleteEntity(TASK, taskId);
messageBus().dispatch(new TaskEvent(user,task,Event.EventType.DELETE));
return sendContent(ex, Map.of(DELETED, taskId));
}
@@ -258,6 +280,16 @@ public class TaskModule extends BaseHandler implements TaskService {
return taskList;
}
private boolean newParentIsSubtask(Task task, long newParent) {
var parent = taskDb.load(newParent);
while (parent != null) {
if (task.id() == parent.id()) return true;
if (parent.parentTaskId() == null) break;
parent = taskDb.load(parent.parentTaskId());
}
return false;
}
private Map<String, Object> placeInTree(Task task, HashMap<Long, Map<String, Object>> taskTree, Map<Long, Task> taskMap) {
var mappedTask = task.toMap();
if (task.parentTaskId() != null) {
@@ -311,20 +343,11 @@ public class TaskModule extends BaseHandler implements TaskService {
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) addMember(task, num.longValue());
if (json.has(PARENT_TASK_ID) && json.get(PARENT_TASK_ID) instanceof Number ptid && newParentIsSubtask(task, ptid.longValue())) throw forbidden("Task must not be sub-task of itself.");
taskDb.save(task.patch(json));
var tagList = tagService().getTags(TASK, taskId, user);
messageBus().dispatch(new TaskEvent(user,new TaggedTask(task,tagList), UPDATE));
return sendContent(ex, task);
}
private boolean newParentIsSubtask(Task task, long newParent) {
var parent = taskDb.load(newParent);
while (parent != null) {
if (task.id() == parent.id()) return true;
if (parent.parentTaskId() == null) break;
parent = taskDb.load(parent.parentTaskId());
}
return false;
}
private boolean postNewTask(UmbrellaUser user, HttpExchange ex) throws IOException {
var json = json(ex);
if (!(json.has(PROJECT_ID) && json.get(PROJECT_ID) instanceof Number pid)) throw missingFieldException(PROJECT_ID);
@@ -381,7 +404,10 @@ public class TaskModule extends BaseHandler implements TaskService {
if ((tagList == null || tagList.isEmpty()) && parentTask != null) tagList = tagService().getTags(TASK, parentTask.id(), user);
if ((tagList == null || tagList.isEmpty())) tagList = tagService().getTags(PROJECT, projectId, user);
if (tagList != null && !tagList.isEmpty()) tagService().save(TASK, task.id(), null, tagList);
return sendContent(ex, loadMembers(task));
task = loadMembers(task);
messageBus().dispatch(new TaskEvent(user,new TaggedTask(task,tagList), CREATE));
return sendContent(ex, task);
}
private boolean postSearch(UmbrellaUser user, HttpExchange ex) throws IOException {
@@ -420,23 +446,6 @@ public class TaskModule extends BaseHandler implements TaskService {
return sendEmptyResponse(HTTP_NOT_IMPLEMENTED, ex);
}
private static class TaggedTask extends Task{
private final Collection<String> tags;
public TaggedTask(Task task, Collection<String> tags) {
super(task.id(), task.projectId(), task.parentTaskId(), task.name(), task.description(), task.status(), task.estimatedTime(), task.start(), task.dueDate(), task.showClosed(), task.noIndex(), task.members(), task.priority());
this.tags = tags;
}
@Override
public Map<String, Object> toMap() {
var map = super.toMap();
map.put(TAGS,tags);
return map;
}
}
private Map<Long, Task> addTags(Map<Long, Task> taskList, Map<Long, ? extends Collection<String>> tags) {
return taskList.values().stream().map(task -> new TaggedTask(task, tags.get(task.id()))).collect(Collectors.toMap(Task::id, t -> t));
}

View File

@@ -343,6 +343,9 @@
"user_list": "Benutzer-Liste",
"user_module" : "Umbrella User-Verwaltung",
"users": "Benutzer",
"user_created_entity": "{user} hat \"{entity}\" angelegt",
"user_deleted_entity": "{user} hat \"{entity}\" gelöscht",
"user_updated_entity": "{user} hat \"{entity}\" bearbeitet",
"website": "Website",
"welcome" : "Willkommen, {0}",

View File

@@ -153,6 +153,10 @@ li.task > button{
display: none;
}
li.task > button:nth-child(2){
display: initial;
}
li.task{
padding: 5px 0;
}

View File

@@ -81,6 +81,11 @@ tr:hover .taglist .tag button {
color: black;
}
.info{
background: orange;
color: black;
}
.kanban .add_task,
.kanban .box,
.kanban .head,

View File

@@ -168,6 +168,15 @@ td, tr{
border-radius: 6px;
}
.info {
position: absolute;
bottom: 0;
right: 0;
font-size: 14px;
z-index: 200;
padding: 3px;
}
.warn {
padding: 5px;
border-radius: 6px;
@@ -231,6 +240,10 @@ li.task > button{
display: none;
}
li.task > button:nth-child(2){
display: initial;
}
li.task{
padding: 5px 0;
}

View File

@@ -19,6 +19,7 @@ body {
code {
font-size: 16px;
}
fieldset {
border: 1px solid;
border-radius: 4px;
@@ -152,6 +153,10 @@ li.task > button{
display: none;
}
li.task > button:nth-child(2){
display: initial;
}
li.task{
padding: 5px 0;
}